npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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

npm install react-auto-thumbnail
cd ios && pod install

Then 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 not

Asking 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 file

Play 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;  // 620

thumbnails 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. MediaMetadataRetriever is 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 batch

On native it holds a file in the app's cache directory:

await deleteThumbnail(shot.file.uri);
await clearCache();  // { removed: 12 } — thumbnails AND cached downloads

The 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. AVAssetImageGenerator defaults 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. MediaMetadataRetriever reports duration 0 and 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 where at: '50%' lands on frame zero. Track formats are read from MediaExtractor, 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