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-media-compressor

v0.1.1

Published

Compress images and videos in React and React Native. Set an exact size limit like 300 KB and get it, or use auto presets. Works on iOS, Android and web with no FFmpeg and no dependencies.

Readme

react-media-compressor

One image and video compressor for React and React Native. Same imports, same options, same result object on both.

import { compressImage, AUTO, MAX, SIZE } from 'react-media-compressor';

const result = await compressImage(file);   // auto — the default

// or ask for a ceiling instead:
//   { mode: MAX(SIZE.KB[450]) }   at most 450 KB
//   { mode: '<=450kb' }           the same thing
//   { mode: AUTO.MB(2) }          auto quality, capped at 2 MB

if (result.status) upload(result.file);
else               showError(result.issue);

MAX is a ceiling, not an exact size. No encoder can hit an exact byte count — they take a quality knob or a bitrate, and a size falls out. Ask for MAX(SIZE.KB[300]) and you get at most 300 KB, usually a little under. That is also the contract every real constraint has: upload limits and API caps are "no bigger than", never "exactly".

Everything you call comes from index.ts; everything you type against comes from types.ts. Nothing else is public.


Why this exists

react-native-compressor is excellent at what it does, but its auto mode for images is a fixed recipe — resize the long edge to 1280, encode at quality 0.8 — and there is no way to say "make this 500 KB". If you need a size, you write the retry loop yourself, re-decoding the image on every pass.

This library keeps the decision-making in TypeScript and treats the platform encoder as a backend:

| | react-native-compressor | this | |---|---|---| | Cap the output size (<=500kb, SIZE.MB[1]) | — | yes, in ~2 encodes | | Runs on the web | — | yes, no dependencies | | Warns when quality was destroyed | — | yes, warningCode | | Predict output size before encoding | — | estimate() | | Result on failure | throws | { status: false, issue } | | Auto mode | fixed 1280 / q0.8 | four presets, adapts to the source | | Decode cost of an N-pass search | N decodes | 1 decode |


Install

npm install react-media-compressor

React (web)

Nothing else. No native build, no config, no runtime dependencies. Works in Vite, Next.js, CRA, Remix and anything else.

import { compressImage, MAX, SIZE } from 'react-media-compressor';

const result = await compressImage(file, { mode: MAX(SIZE.KB[300]) });

On SSR frameworks, call it from the browser. Importing it on the server is safe — it never touches window at import time — it just cannot compress there.

React Native

npx pod-install ios     # iOS only

Then rebuild the app once — a native module needs a new binary, not just a reload. Autolinking does the rest: nothing to add to settings.gradle, MainApplication or your Info.plist.

| | | |---|---| | iOS | 13.4+ | | Android | minSdkVersion 24+, JVM target 17 | | APK growth | +0.4 MB (measured) | | Permissions | none — the package declares no uses-permission and adds no Info.plist keys |

Whatever permissions your app needs are your picker's requirements, not this library's. It reads the uri you hand it using access your app already has.

A ph://… uri (returned by @react-native-camera-roll/camera-roll) is a reference into the photo library rather than a file, so reading it needs your app to declare:

<key>NSPhotoLibraryUsageDescription</key>
<string>We compress the photos you choose before uploading them.</string>

Without it the library returns an explanatory failure rather than letting iOS terminate your app. Most pickers — expo-image-picker, react-native-image-picker, react-native-image-crop-picker — copy the asset out and hand you a file:// uri, which needs nothing.

Likewise on Android, content:// uris from a picker carry their own read grant. You only need READ_MEDIA_IMAGES / READ_MEDIA_VIDEO if you build a MediaStore uri yourself instead of getting one from a picker.

Expo

Needs a development build or prebuild — it is a native module, so not Expo Go.

npx expo install react-media-compressor
npx expo prebuild
npx expo run:ios          # or run:android

Check it worked

import { detectRuntime, listEngines } from 'react-media-compressor';

console.log(detectRuntime(), listEngines());
// react-native -> { image: ['native-image'], video: ['native-video'] }
// web          -> { image: ['web-canvas'],  video: ['web-mediarecorder'] }

Empty on React Native means the JS loaded but the binary did not — rebuild, don't just reload the bundle.

The modes

Two, not three. auto decides the size from the content; max is handed a ceiling and respects it. (Your dynamic/fixed split turned out to be the same mode twice — see the note at the end of this section.)

auto — best size the picture can take without falling apart

The default. Caps the dimensions, then encodes at a quality chosen for the preset. One pass.

await compressImage(file);                    // AUTO.balanced
await compressImage(file, { mode: 'tiny' });
await compressImage(file, { mode: AUTO.best });

| preset | long edge | quality | for | |---|---|---|---| | best | 4096 | 0.92 | archival, print, editing | | balanced (default) | 2560 | 0.82 | almost everything | | small | 1600 | 0.70 | feeds, chat | | tiny | 1080 | 0.55 | thumbnails, previews |

Auto also checks how compressed the source already is. A photo that has been through a lossy encoder before gets encoded at a higher quality than the preset asks for, because stacking a second generation of artefacts onto the first is what makes re-shared pictures look mushy.

auto with a ceiling

AUTO.MB(n) and AUTO.KB(n) mean "look the way auto normally would, but never exceed this". If the auto pass already fits, that is the answer — no extra work. If it does not, the image is made smaller rather than blurrier: quality holds at the preset and the pixel dimensions come down.

await compressImage(file, { mode: AUTO.MB(2) });
await compressImage(file, { mode: AUTO.KB(300) });
await compressImage(file, { mode: AUTO.max('1.5mb', 'small') });

This is the one place the two modes differ in spirit. AUTO.MB(2) treats the number as a limit it hopes not to need; MAX(SIZE.MB[2]) treats it as an allowance to spend. Same guarantee, different ambition.

max — an upper bound on the file size

The output will be at or under the limit. Never over it.

await compressImage(file, { mode: MAX(SIZE.KB[300]) });   // preset
await compressImage(file, { mode: MAX.KB(300) });          // same thing
await compressImage(file, { mode: MAX('1.5mb') });         // parsed
await compressImage(file, { mode: MAX(remainingQuota) });  // computed

It usually lands a little under — the search accepts anything in the top 8% of the allowance rather than burning two more encodes chasing the last few kilobytes. A 300 KB limit typically returns 276–300 KB. Tighten tolerance if you would rather have the passes than the headroom:

await compressImage(file, { mode: MAX.KB(300), tolerance: 0.02 }); // 294–300 KB

If the limit genuinely cannot be met without wrecking the media, you get the closest safe result plus a LIMIT_EXCEEDED warning — never a silent overrun. Pass strict: true to turn that into a failure instead.

Shorthands

A bare size is a limit, which is usually all you want to write:

await compressImage(file, { mode: '<=300kb' });
await compressImage(file, { mode: '<300kb' });     // same
await compressImage(file, { mode: 'max 300kb' });  // same
await compressImage(file, { mode: '300kb' });      // same
await compressImage(file, { mode: SIZE.MB[2] });
await compressImage(file, { mode: 1_500_000 });    // bytes

SIZE is both indexable and callable, and parseSize accepts '500kb', '1.5 MB', '900B' and plain bytes. Units are binary (1 MB = 1048576 B), which is what phone file managers report.

Both are aliases of MAX and still work. They were a distinction without a difference — identical at runtime, separated only by where the number came from, which is not something the library can see. And FIXED actively misled: it named a ceiling as though it were an exact size.

FIXED === MAX;    // true
DYNAMIC === MAX;  // true

The result

One shape, always resolved — never thrown.

// success
{
  status: true,
  file: { uri, name, size, sizeLabel, mime, width, height, blob?, dispose(), … },
  warning: null,
  warningCode: null,
  warnings: [],
  source: { uri, name, size, mime, width, height },
  meta: { mode, engine, attempts, elapsedMs, originalSize, compressedSize,
          ratio, savedBytes, percentSaved, quality, width, height, passthrough }
}

// success, but you should know something
{
  status: true,
  file: { … },
  warning: 'Image had to be compressed hard to fit — pixels can tear and fine detail can smear…',
  warningCode: 'HEAVY_COMPRESSION',
  …
}

// failure
{
  status: false,
  file: null,
  issue: 'Could not bring this image under 30 KB; the closest safe result was 41 KB.',
  issueCode: 'LIMIT_UNREACHABLE',
  …
}

So the call site is one branch:

const result = await compressImage(asset, { mode: MAX(SIZE.MB[1]) });

if (!result.status) return toast(result.issue);
if (result.warningCode === 'HEAVY_COMPRESSION') toast(result.warning);

await upload(result.file);

Warning codes

| code | meaning | |---|---| | HEAVY_COMPRESSION | Quality or bitrate went low enough that artefacts are likely. | | LIMIT_EXCEEDED | The limit could not be met; the closest safe result came back. | | ALREADY_SMALL | Source was already under the limit — returned untouched, no work done. | | NO_GAIN | Compressing made it bigger; the original came back instead. | | BELOW_MIN_SIZE | Source was under minSizeToCompress. | | FORMAT_FALLBACK | Requested format unsupported here; a supported one was used. | | SLOW_ENGINE | A real-time engine did the work (browser video). | | METADATA_DROPPED | EXIF could not be preserved. |

Pass strict: true to turn LIMIT_EXCEEDED and HEAVY_COMPRESSION into a status: false with issueCode: 'LIMIT_UNREACHABLE' instead.

Issue codes

INVALID_INPUT · UNREADABLE · ENGINE_MISSING · ENCODE_FAILED · LIMIT_UNREACHABLE · CANCELLED · TIMEOUT · BAD_OPTIONS · UNKNOWN


API

| function | does | |---|---| | compressImage(input, options?) | compress one image | | compressVideo(input, options?) | compress one video | | compress(input, options?) | detect which it is, then dispatch | | compressBatch(inputs, options?) | many, with bounded concurrency | | probe(input) | metadata only — nothing is written | | estimate(input, options?) | predict the output size without encoding | | configure(config) | set defaults once, at startup | | registerImageEngine / registerVideoEngine | plug in your own backend | | disposeAll(results) | revoke every object URL in one call |

Inputs

A File/Blob, raw bytes, a uri string (file://, content://, ph://, data:, http(s)://), or the asset object an image picker hands you — { uri, fileName, fileSize, width, height } works as-is.

Options

{
  mode,                 // 'auto' | '<=450kb' | SIZE.MB[1] | MAX.KB(300) | AUTO.MB(2)
  output,               // 'auto' | 'webp' | 'jpeg' | 'png' | 'avif' | 'heic' | 'mp4' | 'webm'
  maxDimension,         // cap the long edge, in pixels
  maxWidth, maxHeight,
  fileName,
  quality,              // pin it and skip the search (images)
  minQuality, maxQuality, warnBelowQuality,
  bitrate, minBitrate, maxFps, stripAudio, audioBitrate,   // video
  skipIfSmaller,        // default true
  minSizeToCompress,    // e.g. '80kb'
  tolerance,            // default 0.08 → accept 92–100% of the limit
  maxAttempts,          // images 6, video 1–2
  strict,               // warnings become failures
  signal,               // AbortSignal
  timeoutMs,
  onProgress,           // ({ progress, stage, attempt, bytes })
  returnBase64,
}

Progress and cancellation

const controller = new AbortController();

const result = await compressVideo(uri, {
  mode: MAX(SIZE.MB[10]),
  signal: controller.signal,
  onProgress: ({ progress, stage }) => setBar(progress),
});

// elsewhere
controller.abort();   // → { status: false, issueCode: 'CANCELLED' }

Batch

let done = 0;

const batch = await compressBatch(assets, {
  mode: AUTO.MB(2),
  concurrency: 3,
  // Count completions. `onItem` fires in completion order, not input order,
  // so `(i + 1) / total` would make the bar jump around and go backwards.
  onItem: () => setProgress(++done / assets.length),
});

batch.succeeded;         // CompressSuccess[]
batch.failed;            // CompressFailure[]
batch.percentSaved;      // 87.4

Concurrency defaults to 2 on React Native and 4 on the web: each native encode holds a full bitmap in memory, and four at once is how you get an OOM on a mid-range Android device.

Estimate before you commit

const guess = await estimate(file, { mode: MAX(SIZE.KB[100]) });

if (guess.atRisk) {
  // "Reaching 100 KB at 4032x3024 needs quality below 0.4…"
  confirm(guess.note);
}

How the size targeting works

Hitting a byte target usually means bisecting on quality: encode, compare, halve the interval, repeat — six to ten encodes, and on the web each one drags a fresh decode of the source with it.

This does two things differently.

Decode once, encode many. The web engine holds the decoded bitmap for the whole search, so a six-attempt search costs one decode and six encodes rather than six of each.

Solve, don't bisect. Encoded size is roughly pixels × bits-per-pixel(quality), and the library ships a measured bits-per-pixel curve. The first encode is a model-based guess; comparing what came back against what the model predicted gives a contentFactor for this specific image, and the next guess is solved against the corrected model. A flat graphic and a noisy photograph converge in the same number of passes even though their sizes differ tenfold.

Across a 72-case simulation the search is run against encoder curves it has deliberately not been told about — the fake encoder uses a different exponent than the model — and it still never exceeds the limit and never drops below the quality floor, at an average of 2.2 encodes. On the real encoders it is faster than that: 1–2 passes for auto, 2–5 for a size limit.

One consequence worth stating, because it cost a real bug to learn: the first measurement is never taken below the quality the search intends to use. A demanding target clamps the model's opening guess to the quality floor, and calibrating at 0.40 to then plan at 0.55 means extrapolating along a curve whose shape varies by encoder. Measuring at the comfort quality instead turns that into "how big is this image at the quality I want?", which needs only the pixel-count relationship — and that one holds everywhere.

Shrink before you smear. When quality alone cannot reach the budget the library reduces the pixel count instead, because a small sharp image beats a full-size mushy one. The scale is solved once, from a real measurement, and then locked — letting it drift between attempts is what makes naive loops oscillate. If the locked size still does not fit, it re-solves for a smaller one rather than dropping below the quality floor. How far quality may fall before any of this kicks in depends on the mode: auto defends its preset, size-driven modes may spend down to 0.55 first.

Filling the budget is not the goal. When several attempts land under the target, the library keeps the best-looking one, not the biggest. 96 KB at quality 0.41 and 90 KB at quality 0.55 both "fit" a 100 KB budget; the last 6 KB of an allowance is worth nothing and the extra quality is worth a lot.

Video is arithmetic, not search. For a clip of known duration, output size is very close to (video bitrate + audio bitrate) × duration. Solving that for the bitrate lands inside the limit in one pass. If the required bitrate is too low to hold the resolution together, the resolution steps down a ladder rather than the picture falling apart — and on a tight budget the audio track is trimmed first, since a quieter track is far less noticeable than a blocky picture.


What you actually get, per platform

The same call produces a different format — and so a different size — on each platform, because the encoders differ. This is one real 4352x3264 / 1.54 MB photograph (already a lean 0.91 bpp JPEG) run through the same modes on all three, measured on a booted iPhone simulator, an Android emulator and headless Chrome:

| mode | web (WebP) | Android (WebP) | iOS (JPEG) | |---|---|---|---| | auto | 445 KB | 478 KB | 1.14 MB | | auto small | 143 KB | 144 KB | 441 KB | | auto tiny | 49 KB | 50 KB | 147 KB | | '<=450kb' | 425 KB | 448 KB | 421 KB | | MAX(SIZE.KB[300]) | 280 KB | 291 KB | 288 KB | | MAX(SIZE.KB[100]) | 99 KB | 98 KB | 97 KB |

And the same 1920x1080 / 20.5s / 10.5 MB clip:

| mode | web (real-time) | Android | iOS | |---|---|---|---| | auto | 2.65 MB | 4.17 MB | 3.21 MB | | MAX(SIZE.MB[5]) | 3.68 MB | 4.97 MB | 4.87 MB | | MAX(SIZE.MB[2]) | 1.38 MB | — | — |

Video works on all three, limits included. What differs is the cost: the two native engines finished in 3–27 seconds, the browser took 20.7 seconds for a 20.5 second clip, because it transcodes in real time.

Every limit is respected on every platform. Two things worth reading off those tables. The web and Android columns agree closely despite being completely separate implementations (canvas versus Bitmap.compress), which is a useful cross-check on the search. And auto on iOS is more than twice the size of the other two — not because it is worse, but because iOS cannot write WebP, and at the same numeric quality WebP is far smaller. At an explicit limit all three agree within a few percent; it is the quality at that size that differs. If you want the smaller files on iOS too and you control the server, ask for output: 'heic'.

estimate() is a prediction from a single metadata read, not a promise. On the measurements above it lands within ~10% on the WebP platforms and ~45% low on iOS JPEG, because one content-difficulty number cannot serve two encoders whose quality scales differ that much. Use it to warn ("this may not fit"), not to display a number you will be held to.

Platform notes

Web images — canvas. Prefers WebP (25–35% smaller than JPEG at matched quality), falling back to JPEG where it is unavailable. Support is detected by encoding a probe and checking the blob's own type, because browsers that cannot write a format silently hand back a PNG. Downscaling steps down by halves rather than in one jump, which keeps text and fine texture from aliasing.

Web video — MediaRecorder. This is a real-time transcoder: a 30-second clip takes about 30 seconds, and you get a SLOW_ENGINE warning saying so. That is the honest cost of transcoding video in a browser without shipping a multi-megabyte WASM codec. It needs no dependencies, and one pass is normally enough because bitrate maps almost linearly to output size. Output is MP4 where the browser can record it (current Chrome, Edge and Safari 17+ all can), WebM otherwise. Trigger it from a user interaction — browsers block the playback it needs otherwise.

One caveat worth knowing before you rely on it. Recording follows the wall clock, not the source's timeline, so on a machine that cannot decode and re-encode as fast as the clip plays, the output ends up longer than the input — and proportionally larger, which can blow past the target. The library reads the duration back off the finished file rather than assuming it, and tells you plainly when the two disagree:

This engine transcodes in real time and could not keep up: the output runs 6.9s against the source's 4.0s, which also made it 74% larger than planned.

If you hit this, pass maxAttempts: 2. The second pass now has a measured duration to work from and corrects the bitrate — at the cost of transcoding twice. Native engines do not have this problem: they encode faster than playback, so they get the corrective pass by default.

The engine also skips reserving bitrate for audio when the source has none (screen recordings, exported timelapses), which is otherwise ~96 kbps of a tight budget spent on silence.

Android images — BitmapFactory with inSampleSize subsampling, writing JPEG, PNG or WebP. EXIF rotation is baked into the pixels at decode, so the picture comes out upright.

iOS camera-roll assets — a picker that hands you ph://<uuid>/L0/001 instead of a file path is supported, but it needs one thing from the host app: an NSPhotoLibraryUsageDescription key in Info.plist. Without it, any call into Photos terminates the process — TCC sends SIGABRT, it is not a catchable error — so the library checks for the key before touching the photo library at all and returns an explanatory failure instead of killing your app. Apps that only ever pass file:// uris need nothing.

iOS images — ImageIO. Each attempt is a CGImageSourceCreateThumbnailAtIndex, which subsamples during decode rather than decoding full size and scaling afterwards. Writes JPEG, PNG or HEIC (output: 'heic', roughly 45% smaller than JPEG — opt in, since plenty of servers still reject it).

Note the format asymmetry: Android can write WebP and iOS cannot; iOS can write HEIC and Android cannot. Both default to JPEG, which everything accepts. On the web WebP is the default, so the same auto call legitimately produces a different format — and a different size — per platform.

Video — Media3 Transformer on Android, AVAssetWriter on iOS. Both encode several times faster than playback, so the planner takes up to three passes to land inside the limit.

One Android quirk worth knowing about, because it cost real debugging: a fragmented MP4 reports a duration of 0 from both MediaMetadataRetriever and MediaExtractor — no error, the field simply is not there. Its moov is only an initialisation segment, and unless the file carries an optional mehd box the length exists solely as the sum of its moof fragments. Plenty of stock and streaming-oriented footage is written this way. AVFoundation sums the fragments and so does Media3's own extractor, but the platform metadata APIs do not — and every size calculation starts from duration, so believing them means MAX cannot work at all on those files. This library falls back to asking the demuxer where its last sample sits, which works whatever the container.

All the mode logic, the search, the warnings and the result shape are the same TypeScript on every platform. Only the encode call differs.


Renames

Everything below still works; the old name is an alias, and the new one is what the docs use.

| was | now | why | |---|---|---| | FIXED(...) | MAX(...) | "Fixed" promised an exact size. It was always a ceiling. | | DYNAMIC(...) | MAX(...) | Identical to FIXED at runtime — one behaviour, one name. | | MODE.FIXED / MODE.DYNAMIC | MODE.MAX | Same. | | AUTO.under(...) | AUTO.max(...) | Matches MAX, and is inclusive — 2 MB exactly is allowed. | | options.maxSize | options.maxDimension | It means pixels. "Size" reads as bytes, which this library also takes. | | meta.targetBytes | meta.limitBytes | Same vocabulary as the mode. | | 'TARGET_MISSED' | 'LIMIT_EXCEEDED' | Says what happened: the output went over the limit. | | 'TARGET_UNREACHABLE' | 'LIMIT_UNREACHABLE' | Same. |

The last two are the only ones without an alias — a string union member cannot be aliased, so a switch on warningCode needs the new name.

Custom engines

import { registerImageEngine } from 'react-media-compressor';

registerImageEngine({
  name: 'my-encoder',
  formats: ['webp', 'jpeg'],
  async probe(input, ctx) { /* … */ },
  async open(input, ctx) {
    return {
      width, height, size, mime, hasAlpha,
      async encode({ width, height, quality, format }) {
        return { bytes, mime, width, height, data /* Blob | Uint8Array | uri */ };
      },
      close(keep) { /* clean up everything except `keep` */ },
    };
  },
});

Higher priority wins; the built-in web engine sits at 0, the shipped adapters at 15–20.

Full text reference

Everything above, plus every mode, option, warning code, recipe and troubleshooting entry, is in USAGE.txt — plain text, no markdown, shipped inside the package so it is available offline:

cat node_modules/react-media-compressor/USAGE.txt

It is written to be copied from: every block is a working snippet, and every number in it was measured on a device rather than estimated.

License

MIT