react-auto-thumbnail
v0.2.1
Published
Video thumbnails for React and React Native. Grab the first frame worth showing, or any time you name — '50%', '00:01:23', 2.5s, 2s before the end. Works on iOS, Android and web with no FFmpeg and no dependencies.
Downloads
33
Maintainers
Readme
Point it at a video and get an image back — the first frame worth showing, or any time you name. iOS, Android and web, no FFmpeg, no dependencies.
import { thumbnail } from 'react-auto-thumbnail';
const shot = await thumbnail(video);
if (shot.status) {
form.append('thumbnail', shot.file.toFormDataValue());
} else {
console.log(shot.issue);
}Contents
- Install
- Why "auto" is not frame zero
- Picking a time — every mode
- Options — every option
- Tuning the scan
- Results — success, failure, warnings
- API reference — every export
- Recipes — the things people actually build
- Inputs
- Remote videos — and the range-request trap on both platforms
- Cleaning up
- How it works
- Bring your own engine
- Troubleshooting
Install
npm install react-auto-thumbnail
cd ios && pod installThen rebuild the app. The native side ships in the binary, so reloading the
JS bundle is not enough — if you skip this you get a clear ENGINE_MISSING
result saying exactly that, rather than a mysterious failure.
Web needs no build step. Nothing to configure, nothing to register.
A file:../react-auto-thumbnail install is a symlink outside your project
root, and Metro's resolver will not follow it. Add both entries:
// metro.config.js
const path = require('path');
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const localPackages = [path.resolve(__dirname, '../react-auto-thumbnail')];
module.exports = mergeConfig(getDefaultConfig(__dirname), {
watchFolders: localPackages,
resolver: {
nodeModulesPaths: [
path.resolve(__dirname, 'node_modules'),
...localPackages.map(pkg => path.resolve(pkg, 'node_modules')),
],
},
});watchFolders lets Metro read the source; nodeModulesPaths lets it resolve
the library's own imports. Node's require.resolve succeeds without these —
it is a different resolver, so it is not evidence that Metro can resolve it.
Restart with --reset-cache afterwards. None of this is needed for a normal
registry install.
Why "auto" is not frame zero
thumbnail(video) with no options does not hand you the frame at t=0. It
starts there and walks forward until it finds a frame that carries actual
detail.
That is the whole point of the default. Plenty of real footage opens on black — a fade-in, a camera still settling its exposure, a leading blank frame from a screen recorder — and a black rectangle is a worse thumbnail than no thumbnail at all. "Detail" is measured as luminance spread, not brightness, so a genuinely dark night shot is kept while flat black, flat white and single-colour slates are skipped.
An explicit time is honoured exactly:
await thumbnail(video); // scans for something worth showing
await thumbnail(video, { at: 12 }); // the frame at 12s, dark or notAsking for at: 12 and silently getting 13.4s back would be the library
second-guessing a caller who already said what they want. If you do want
scanning from an explicit start, ask for it: { at: 12, scan: true }.
Picking a time
at takes whatever reads most naturally at the call site. Every form below is
supported.
| at | Means | Needs duration? |
| --- | --- | --- |
| (omitted) / 'auto' / AUTO | the first frame worth showing | no |
| 2 / 2.5 | seconds from the start | no |
| -3 | 3 seconds before the end | yes |
| '2s' / '2 sec' / '2 seconds' | seconds | no |
| '1500ms' | milliseconds | no |
| '2min' / '2 minutes' | minutes | no |
| '00:01:23' / '01:23' / '00:00:02.5' | timecode (hh:mm:ss or mm:ss) | no |
| '50%' / '12.5 %' | fraction of the clip | yes |
| 'start' / 'first' / 'begin' / START | the first frame, blank or not | no |
| 'middle' / 'mid' / 'half' / 'center' / MIDDLE | halfway | yes |
| 'end' / 'last' / 'final' / END | the last frame | yes |
| AT.seconds(2) | explicit, when a bare number is unclear | no |
| AT.percent(25) | a quarter of the way through | yes |
| AT.fromEnd(3) | 3 seconds before the end | yes |
| AT.frame(45, 30) | frame 45 at 30 fps → 1.5s | no |
Keywords are case-insensitive. Percentages outside 0–100 are clamped rather
than rejected.
When the duration is unknown. Fragmented MP4 and some screen recorders
report no duration. Relative times then fall back to seconds from the start and
you get an UNKNOWN_DURATION warning instead of a failure — the call still
returns an image.
Times outside the clip are pulled back inside it and flagged with
TIME_CLAMPED. 'end' deliberately lands a hair before the duration, since
the frame exactly at duration is the moment after the video stops and
decoders return nothing for it.
Options
await thumbnail(video, {
at: 'auto', // where to grab — see the table above
format: 'auto', // 'auto' | 'jpeg' | 'png' | 'webp'
quality: 0.82, // 0..1, ignored for png
maxDimension: 640, // cap the longest edge; aspect ratio always preserved
maxWidth: 640, // or cap each axis independently
maxHeight: 480,
scan: true, // blank-frame search — see below
fileName: 'cover.jpg',
base64: false, // also return the bytes as base64
signal: controller.signal,
});| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| at | TimeInput | AUTO | Any form from the table above. |
| format | 'auto' \| 'jpeg' \| 'png' \| 'webp' | 'auto' | See the format table below. |
| quality | number 0–1 | 0.82 | Ignored for PNG (lossless). |
| maxDimension | number px | none | Caps the longest edge. Never upscales. |
| maxWidth | number px | maxDimension | Caps width only. |
| maxHeight | number px | maxDimension | Caps height only. |
| scan | boolean \| ScanOptions | on for AUTO, off otherwise | Blank-frame search. |
| fileName | string | <video>-thumb.<ext> | Extension is corrected for you. |
| base64 | boolean | false | Costs a copy; off unless asked. |
| signal | AbortSignal | none | Honoured between decodes and before encoding. |
Aspect ratio is always preserved — the caps define a box the frame fits inside, they never stretch it.
Formats
| format | Web | iOS | Android |
| --- | --- | --- | --- |
| 'auto' | WebP, else JPEG | JPEG | JPEG |
| 'jpeg' | JPEG | JPEG | JPEG |
| 'png' | PNG | PNG | PNG |
| 'webp' | WebP, else JPEG + warning | JPEG + warning | WebP |
'auto' prefers WebP on the web — 25–35% smaller than JPEG at the same
quality. On iOS and Android it resolves to JPEG, and that asymmetry is
deliberate: iOS cannot encode WebP at all (ImageIO reads it, never writes
it), and JPEG is the one format every backend, image pipeline and <img>
accepts without anyone having to check. Where a format is substituted you get a
FORMAT_FALLBACK warning and the real type in file.mime, so you never have
to guess what you just made.
Tuning the scan
await thumbnail(video, { scan: false }); // take the time as given
await thumbnail(video, { scan: true }); // defaults
await thumbnail(video, { scan: { steps: 12, stepSeconds: 0.5, minDetail: 0.05 } });| Field | Default | Meaning |
| --- | --- | --- |
| steps | 8 | Extra frames to try. 0 disables the search. |
| stepSeconds | 0.35 | Seconds between candidates. |
| minDetail | 0.02 | Detail a frame needs to be accepted, 0–1. |
Each step is one extra frame decode, so the default is deliberately shallow:
eight candidates 0.35s apart covers a typical fade-in without turning a
thumbnail into a video scan. A long fade wants { steps: 20, stepSeconds: 0.5 }.
What minDetail measures. Standard deviation of Rec. 709 luminance across
a 32×32 downsample, normalised to 0–1. Roughly:
| Frame | Score |
| --- | --- |
| flat black, flat white, solid colour slate | ~0.00 |
| a dark but real night shot | 0.05+ |
| an ordinary daylight frame | 0.15+ |
| half black / half white (the theoretical maximum) | 1.00 |
Spread rather than brightness is the point — mean brightness would throw away
every genuinely dark frame and happily accept a solid grey card. The same
number is computed in TypeScript, Swift and Kotlin, so a threshold means the
same thing on every platform. It is exported as detailScore() if you want to
score frames yourself.
If the search runs out of candidates without clearing the threshold you still
get an image — the best frame it saw — plus a BLANK_FRAME or SCAN_EXHAUSTED
warning. It never fails just because the video is dark.
Results
Nothing throws. thumbnail() always resolves, and you check status:
const shot = await thumbnail(video);
if (shot.status) {
shot.file.uri; // object URL on web, file:// on native
shot.file.width; // 360
shot.file.height; // 640
shot.file.size; // 34871
shot.file.sizeLabel; // '34 KB'
shot.file.mime; // 'image/jpeg'
shot.file.atSeconds; // 1.733 — where the frame actually came from
shot.file.path; // native only
shot.file.blob; // web only
shot.file.base64; // only if you asked
shot.meta.requestedSeconds; // 0 — what you asked for
shot.meta.actualSeconds; // 1.733 — what you got
shot.meta.framesInspected; // 6 — how many the scan looked at
shot.meta.detail; // 0.42 — detail score of the chosen frame
shot.meta.elapsedMs; // 214
shot.meta.engine; // 'native-avfoundation-mediametadata'
shot.source.duration; // 13.078 — the clip, not the thumbnail
shot.source.width; // 720
shot.warning; // human-readable, or null
shot.warningCode; // 'BLANK_FRAME' | … | null
} else {
shot.issue; // 'That file has no video track to take a frame from.'
shot.issueCode; // 'NO_VIDEO_TRACK'
}That contract exists because a missing thumbnail is almost never worth failing an upload for — the video is the thing the user cares about.
Issue codes
| Code | Means |
| --- | --- |
| ENGINE_MISSING | Native module not in this build (rebuild), or no DOM on web. |
| UNREADABLE | The file or URL could not be opened. |
| NO_VIDEO_TRACK | Audio-only file, or not a video. |
| DECODE_FAILED | Opened, but no frame could be decoded. |
| ENCODE_FAILED | Frame decoded, but the image could not be written. |
| UNWRITABLE | The cache directory refused the file. |
| CANCELLED | Your AbortSignal fired. |
| BAD_OPTIONS | An option or at value could not be read. |
| UNKNOWN | Anything unclassified. |
Warning codes
Warnings mean "here is your image, with a caveat" — never a failure.
| Code | Means |
| --- | --- |
| BLANK_FRAME | Every inspected frame looked blank; best one returned. |
| SCAN_EXHAUSTED | Search hit the end of the clip early. |
| TIME_CLAMPED | Requested time was outside the clip. |
| UNKNOWN_DURATION | No duration, so a relative time became absolute. |
| FORMAT_FALLBACK | Requested format unsupported here; see file.mime. |
warnings is the full list, most important first; warning and warningCode
are the top one, for when you just want to surface something.
API reference
Main
| Export | Signature | Notes |
| --- | --- | --- |
| thumbnail | (input, options?) => Promise<ThumbnailResult> | One frame. Never throws. |
| thumbnails | (input, options?) => Promise<BatchResult> | Several frames from one clip. |
| probe | (input) => Promise<ProbeResult> | Metadata, no frame decode. Throws. |
| localCopy | (input) => Promise<LocalCopy> | Local file:// path, downloading if remote. Throws. |
Time
| Export | Notes |
| --- | --- |
| AUTO | The first frame worth showing. The default. |
| START | Exactly the first frame, blank or not. |
| MIDDLE | Halfway. |
| END | The last frame. |
| AT.seconds(n) AT.percent(n) AT.fromEnd(n) AT.frame(i, fps?) | Explicit builders. |
| resolveTime(input) | Normalise any at value into a TimeSpec. |
| toSeconds(spec, duration) | Resolve a spec against a real duration. |
| isTimeSpec(value) | Type guard. |
Housekeeping
| Export | Notes |
| --- | --- |
| clearCache() | Delete every file this library wrote. Returns { removed }. |
| deleteThumbnail(uri) | Delete one produced thumbnail. |
| disposeAll(results) | Release object URLs across a batch (web). |
Introspection & extension
| Export | Notes |
| --- | --- |
| detailScore(pixels, stride?) | Score RGBA pixel data 0–1, the same way the engines do. |
| DEFAULT_MIN_DETAIL DEFAULT_SCAN_STEPS DEFAULT_SCAN_STEP_SECONDS | The scan defaults, as values. |
| formatBytes(n) formatSeconds(n) | The formatters used in results and warnings. |
| registerEngine(engine, priority?) | Plug in your own extractor. |
| hasEngine() listEngines() | What is registered — handy in bug reports. |
| detectRuntime() isWeb() isReactNative() | Runtime detection, lazy and SSR-safe. |
| ThumbnailError | The internal error class, for instanceof. |
Types
ThumbnailResult ThumbnailSuccess ThumbnailFailure ThumbnailFile
ThumbnailOptions ThumbnailMeta ThumbnailFormat ScanOptions
BatchOptions BatchResult ProbeResult SourceInfo LocalCopy
TimeInput TimeSpec TimeKind VideoInput UriInput OutputFormat
IssueCode WarningCode Warning RuntimeName ThumbnailEngine
FrameRequest ExtractedFrame EngineContext
Recipes
Upload a thumbnail alongside a video
The main reason this library exists. Generate from the local picked file before uploading — it is fast, and it sidesteps every remote-decoding problem.
const { assets } = await launchImageLibrary({ mediaType: 'video' });
const asset = assets[0];
const shot = await thumbnail(asset, { maxDimension: 720, format: 'jpeg' });
const form = new FormData();
form.append('video', { uri: asset.uri, name: asset.fileName, type: asset.type });
if (shot.status) {
form.append('thumbnail', shot.file.toFormDataValue());
}
// No thumbnail? Still upload the video.
await axios.post('/api/videos/', form);A poster for a video that has no stored thumbnail
Generate once, cache by URL, fall back to a placeholder.
const cache = new Map<string, string>();
function VideoPoster({ src }: { src: string }) {
const [uri, setUri] = useState(cache.get(src));
useEffect(() => {
if (uri) return;
thumbnail(src, { maxDimension: 480 }).then(r => {
if (r.status) {
cache.set(src, r.file.uri);
setUri(r.file.uri);
}
});
}, [src, uri]);
return uri ? <Image source={{ uri }} /> : <Placeholder />;
}Cost it honestly before putting this in a long list — on iOS a remote video is downloaded in full first. See Remote videos.
A filmstrip or scrubbing preview
const strip = await thumbnails(video, {
at: ['0%', '25%', '50%', '75%', -1],
maxDimension: 160,
});
strip.results.forEach((r, i) => {
if (r.status) console.log(i, r.file.uri, r.file.atSeconds);
});
console.log(`${strip.succeeded}/${strip.results.length} in ${strip.elapsedMs}ms`);Or space them yourself from the real duration:
const { duration } = await probe(video);
const times = Array.from({ length: 10 }, (_, i) => (duration * i) / 10);
const strip = await thumbnails(video, { at: times, maxDimension: 160 });Let someone pick their own cover
const candidates = await thumbnails(video, {
at: ['auto', '25%', '50%', '75%'],
maxDimension: 320,
onItem: (r, i, total) => setProgress((i + 1) / total),
});
const options = candidates.results.filter(r => r.status);
// show `options` in a picker; upload the chosen one's filePlay a remote video on iOS when the server has no range support
const [source, setSource] = useState(remoteUri);
useEffect(() => {
if (Platform.OS !== 'ios') return;
localCopy(remoteUri).then(l => setSource(l.uri)).catch(() => {});
}, [remoteUri]);
<Video source={{ uri: source }} />On the web, from a file input
const file = input.files[0]; // a File is a Blob
const shot = await thumbnail(file, { format: 'auto', maxDimension: 640 });
if (shot.status) {
img.src = shot.file.uri; // object URL
// later:
shot.file.dispose();
}Cancel work the user navigated away from
const controller = new AbortController();
useEffect(() => () => controller.abort(), []);
const shot = await thumbnail(video, { signal: controller.signal });
if (!shot.status && shot.issueCode === 'CANCELLED') return;Pick the brightest of several candidates yourself
const strip = await thumbnails(video, { at: [0, '25%', '50%', '75%'], scan: false });
const best = strip.results
.filter(r => r.status)
.sort((a, b) => b.meta.detail - a.meta.detail)[0];Several frames at once
const strip = await thumbnails(video, { at: [0, '50%', -1] });
strip.results; // in INPUT order, whatever order they finished in
strip.succeeded; // 3
strip.failed; // 0
strip.elapsedMs; // 620thumbnails takes every thumbnail option, plus:
| Option | Default | Notes |
| --- | --- | --- |
| at | ['auto'] | A single time or an array of them. |
| concurrency | 1 on RN, 2 on web | How many decode at once. |
| stopOnError | false | Skipped items come back as CANCELLED. |
| onItem | — | (result, index, total), in completion order. |
Concurrency defaults low on React Native deliberately: each native decode holds a full frame buffer, and running four at once on a mid-range phone is how you get killed for memory rather than how you go faster.
onItem fires in completion order, so index arrives shuffled — count
completions for a progress bar rather than using index / total.
Metadata without decoding
const info = await probe(video);
// { width, height, duration, size, sizeLabel, mime, hasVideo, hasAudio }Useful for spacing a filmstrip's times, or deciding whether to bother. Unlike
thumbnail() this throws, because there is no partial answer worth shaping
a result object around.
Inputs
| Platform | Accepts |
| --- | --- |
| Web | Blob, File, ArrayBuffer, Uint8Array, a URL string, or { uri } |
| React Native | file://, content://, ph://, assets-library://, http(s)://, or the picker asset itself |
Passing the picker asset straight through is the intended path — the library
reads fileName, type, fileSize and duration off it and skips work it
would otherwise have to do:
const { assets } = await launchImageLibrary({ mediaType: 'video' });
const shot = await thumbnail(assets[0]);A duration reported in milliseconds is detected and converted, because pickers
disagree about the unit. A bare type: 'video' (no subtype) is not treated as
a MIME type. ph:// and assets-library:// use the Photos framework, which is
touched only for those schemes — an app that never passes one never
triggers a permission prompt.
Raw bytes and blobs are a web shape; on React Native they are rejected with a
clear BAD_OPTIONS rather than being written to a temp file behind your back.
Remote videos
Pass an http(s) URL and it works — but there is a wrinkle worth knowing
about, because it also affects your video player.
Neither mobile platform can be relied on to open a remote video, because
both want HTTP range requests and plenty of servers do not serve them. A
Range: bytes=0-1023 that comes back as 200 with the whole body instead of
206 Partial Content is enough to break them. Django's default file serving
does exactly that, and so does any object store behind a naive proxy.
- iOS aborts immediately with "Operation Stopped" — AVFoundation's very
first request for any remote asset is
Range: bytes=0-1. - Android fails quietly.
MediaMetadataRetrieveris not the stack ExoPlayer uses — it is the platform's legacy media HTTP path, it wants ranges too, and on plenty of OEM builds it refuses remote URLs outright. When it gives up it does not throw: it reports width 0 and no duration, which reads as "no video track" rather than "could not fetch it". So a video that plays fine in your player can still fail to yield a thumbnail.
So on both platforms the library tries the remote URL directly, and if that
does not produce a usable frame it downloads the file once with a plain GET
(which those same servers serve happily) and decodes locally. That is
transparent — thumbnail(remoteUrl) just works — but it is not free.
Trying direct first matters: against a server that does support ranges, neither platform needs the download, and forcing one would turn a streaming metadata read into a full transfer. The escalation is remembered per URL, so only the first call pays for discovering the server is broken.
const local = await localCopy(remoteUrl);
// { uri: 'file://…', size: 4495950, downloaded: true }localCopy is exported because a player hits the identical wall on iOS. No
react-native-video prop, buffer setting, or MP4 "faststart" layout works
around it — making the file faststart does not help, because the failure
happens on that first two-byte probe, before file layout matters. A local file
does.
Two better fixes, where you control the backend:
- Serve media with range support. In nginx,
location /media/ { alias …; }rather than proxying to your app server. Then iOS streams normally and nothing is downloaded. - Store a thumbnail at upload time and never ask a client to decode a video for a 120px tile.
On the web, a cross-origin video additionally needs permissive CORS headers, or the canvas is tainted and the frame cannot be read back out.
Cleaning up
On the web a result holds an object URL:
shot.file.dispose(); // one
disposeAll(strip.results); // a batchOn native it holds a file in the app's cache directory:
await deleteThumbnail(shot.file.uri);
await clearCache(); // { removed: 12 } — thumbnails AND cached downloadsThe OS reclaims the cache directory under pressure either way; these are for
when you know you are done. clearCache() also clears videos pulled down by
localCopy, which is where the bytes actually are.
How it works
| Platform | Engine |
| --- | --- |
| iOS | AVAssetImageGenerator, preferred track transform applied, zero seek tolerance |
| Android | MediaMetadataRetriever for frames, cross-checked against MediaExtractor for metadata |
| Web | a hidden <video> seeked and drawn into a <canvas> |
All three ship with the platform, so this adds no codec and no megabytes to your app. An FFmpeg build would add 4–9 MB to do what the OS already does.
Two details worth knowing about, since both are easy to get wrong:
- iOS seek tolerance.
AVAssetImageGeneratordefaults to infinite tolerance in both directions, so "the frame at 12s" can legitimately return the keyframe at 4s. Both tolerances are set to zero here, which costs a little more decoding and means the requested time is the time you get. - Android metadata.
MediaMetadataRetrieverreports duration0and a null audio flag on plenty of real files while getting width and height right. Since every time calculation starts from duration, believing it silently produces a library whereat: '50%'lands on frame zero. Track formats are read fromMediaExtractor, with the retriever only as a fallback.
Portrait clips are handled: a recording that stores landscape dimensions plus a 90° rotation is reported and rendered the way a viewer actually sees it.
Bring your own engine
import { registerEngine } from 'react-auto-thumbnail';
registerEngine({
name: 'my-engine',
formats: ['jpeg'],
probe: async input => ({ width, height, duration, size, sizeLabel, mime, hasVideo, hasAudio }),
extract: async (input, request, context) => ({
data, mime, width, height, atSeconds, framesInspected, detail, size,
}),
});The library owns time normalisation, option defaults, result shaping and the
never-throw contract; an engine only has to open a clip, hand back frames and
say how much detail each one carried. Higher priority wins — the built-ins
sit at 0 (web) and 5 (native), so the default 10 overrides them.
Troubleshooting
| Symptom | Cause |
| --- | --- |
| issueCode: 'ENGINE_MISSING' on RN | The app was not rebuilt after install. pod install, then rebuild — a JS reload cannot add native code. |
| Unable to resolve module react-auto-thumbnail | A file:/monorepo install without watchFolders. See Install. |
| Black or empty thumbnail | The clip really does open on black and scan was disabled. Try { scan: { steps: 20, stepSeconds: 0.5 } }. |
| issueCode: 'UNREADABLE' on a remote URL | Server does not support range requests and the fallback download failed. See Remote videos. |
| Frame comes back from the wrong time | Another engine is registered — check listEngines(). |
| SecurityError / blank frame on web | Cross-origin video without permissive CORS; the canvas is tainted. |
| at: '50%' lands on frame 0 | The container reports no duration — check for an UNKNOWN_DURATION warning. |
| Asked for WebP, got JPEG | iOS cannot encode WebP. Check warningCode === 'FORMAT_FALLBACK'. |
License
MIT
