
React Native Video Compression for Android & iOS
Muhammad Kamran
Videos shot on a phone are big, and the moment your React Native app has to upload one, store one or pass one around, you start wanting a smaller copy. react-native-media-transformer is the library I wrote for that job. It compresses, resizes and trims video on the device itself, using Android Media3 on one side and iOS AVFoundation on the other. The file never leaves the phone to get processed.
Alongside compression there is a metadata reader and a thumbnail generator. It is MIT licensed, and this post is about the 0.1 line.
Getting it into your app
npm install react-native-media-transformer react-native-nitro-modules@^0.37.1
cd ios && pod installIt is a native module, so rebuild your app after installing. A Metro reload will not add native code. The library works on Android API 24+ and iOS 15.1+, or whatever higher minimum your React Native version asks for. It is built on Nitro Modules, which is why the second package is in that command.
The example app in the repo runs React Native 0.86.2 with Nitro 0.37.1, and that is the combination it has been verified against. Other React Native versions may work, but I have not confirmed them. If you are on Expo, you need a development build. Expo Go cannot load native code like this, and web is not supported either.
Your first compression
import { compress } from 'react-native-media-transformer';
const outputPath = await compress(
inputUri,
{ quality: 'medium', maxHeight: 720 },
(progress) => console.log(`${Math.round(progress * 100)}%`)
);inputUri is whatever your video picker gave you. What comes back is an absolute path to an MP4 in the app cache, H.264 video with AAC audio when the source has audio. If a component wants a URI instead of a path, put file:// in front of it.
With no options at all, dimensions stay as they are (rounded to even pixels) and the target bitrate is picked from the source's bitrate and dimensions. The video is always re-encoded.
Deciding how small is small
The quickest way is a quality preset:
Preset | Max width and height | Video bitrate | Audio bitrate |
|---|---|---|---|
| 640 × 640 | 700 kbps | 64 kbps |
| 1280 × 1280 | 2 Mbps | 128 kbps |
| 1920 × 1920 | 5 Mbps | 192 kbps |
Both limits apply, so a portrait and a landscape video end up with the same maximum long edge. Anything you set explicitly overrides the preset's own value. maxWidth and maxHeight work as a bounding box: aspect ratio and orientation are kept, nothing is upscaled, and the encoded size is rounded down to even pixels.
Beyond presets you can set bitrate, codec ('h264' or 'hevc'), maxFrameRate, mute, audioBitrate, audioSampleRate, audioChannels, outputFileName, overwrite, progressInterval and optimizeForNetworkUse. Trimming is just startTime and endTime in seconds. This keeps ten seconds of a clip and encodes it as HEVC:
const clip = await compress(inputUri, {
quality: 'medium',
codec: 'hevc',
startTime: 2,
endTime: 12,
});Every time value in the library is in seconds, sizes are in bytes, dimensions are displayed pixels and bitrates are bits per second. Invalid options reject the promise.
Progress and cancelling
Progress starts at 0 and reaches 1 only after the output file is complete. Cancelling is a call to cancelCompress(), and a cancelled export rejects the promise, so wrap compress in try/catch.
import { compress, cancelCompress } from 'react-native-media-transformer';
try {
await compress(inputUri, { maxHeight: 720 });
} catch (error) {
console.error(error);
}
await cancelCompress();The top-level functions share a single session. Start a new compression and the previous one gets cancelled and rejected. When you want two exports that do not interfere, create separate sessions:
import { createVideoCompressor } from 'react-native-media-transformer';
const editor = createVideoCompressor();
const uploader = createVideoCompressor();
const [preview, upload] = await Promise.all([
editor.compress(firstVideo, { quality: 'low' }),
uploader.compress(secondVideo, { quality: 'high' }),
]);Each session's cancel() only touches its own export. Running exports side by side needs enough codec resources on the device, so for lots of files a batch (below) is the gentler option.
Inside a component
If you would rather not manage state yourself, there is a hook:
import { useVideoCompressor } from 'react-native-media-transformer';
import { Button, Text, View } from 'react-native';
export function VideoCompressor({ inputUri }: { inputUri: string }) {
const { compressVideo, cancel, isCompressing, progressPercent, error, outputPath } =
useVideoCompressor();
const start = async () => {
try {
await compressVideo(inputUri, { quality: 'medium' });
} catch {}
};
return (
<View>
<Button title='Compress' disabled={isCompressing} />
{isCompressing && <Button title='Cancel' => cancel().catch(console.error)} />}
<Text>{progressPercent}%</Text>
{error && <Text>{error.message}</Text>}
{outputPath && <Text>{outputPath}</Text>}
</View>
);
}The hook also gives you progress as a 0 to 1 fraction and a reset() that cancels the export and clears the state. Each hook owns its own session and cancels it when the component unmounts. Resetting does not delete files that were already finished.
A pile of videos
compressBatch takes a list of inputs, checks all the options up front, and then works through the files one at a time in its own session. Results come back in the same order you passed them in.
import { compressBatch } from 'react-native-media-transformer';
const controller = new AbortController();
const outputs = await compressBatch(
[
{ inputUri: firstVideo, options: { quality: 'low' } },
{ inputUri: secondVideo, options: { maxHeight: 720, mute: true } },
],
{
signal: controller.signal,
onProgress: ({ index, progress }) => console.log(index, progress),
}
);Progress events carry the zero-based index, the total, the current file's itemProgress and the overall progress. A batch stops at the first failure. Calling controller.abort() cancels the active export and rejects with an AbortError. Files that were already finished stay in the cache, while temporary files from the failed or cancelled one are cleaned up. Give each item a unique outputFileName if you set one.
Metadata and thumbnails
import { getVideoMetadata, createThumbnail } from 'react-native-media-transformer';
const info = await getVideoMetadata(outputPath);
const thumbnail = await createThumbnail(outputPath, {
maxWidth: 480,
maxHeight: 320,
});
const thumbnailUri = `file://${thumbnail.path}`;getVideoMetadata returns width and height (after rotation is applied), duration in seconds, size in bytes, bitrate, frameRate, hasAudio and rotation. On Android size is 0 when a content provider cannot report it, and frameRate is 0 when the nominal rate is not available.
createThumbnail writes a JPEG and gives back its path, width and height, with the source orientation applied. You can choose the time in seconds (the decoder picks a frame at or near it), the bounding size, a JPEG quality from 0 to 1 (default 0.8) and an outputFileName ending in .jpg.
Where files come from and where they go
On Android you can pass absolute paths, file:// URLs and content:// URIs, as long as the read grant stays valid for the whole operation. On iOS, absolute local paths and file:// URLs work, and a Photos asset (ph://) needs to be copied to a local file first. A picker set up to return local files works directly. Remote media has to be downloaded before processing.
Outputs land in the app cache under a media-transformer folder, and the OS is free to clear that cache. If a file matters, copy it to permanent storage. Exports are written to a temporary file and published atomically, so a failed or cancelled export does not damage a file you already produced. Existing outputs are never replaced unless you pass overwrite: true.
The library does not ask for photo-library or storage permissions. Your app decides what to request and where to keep things. The example app shows saving to the gallery with CameraRoll.
Worth knowing before you ship
Encoders belong to the device, not to the library. HEVC, bitrate, frame rate and audio settings all depend on what the phone's hardware supports, and settings it cannot handle reject the operation instead of silently falling back. If HEVC fails on some device, retrying with H.264 is a decision your app makes.
Re-encoding a video that was already efficient can make it larger, so compare the result with the original before you replace anything. No preset promises a particular size reduction. HDR and Dolby Vision are not guaranteed to survive, and tone mapping can differ between devices, so test on real hardware with the kind of videos your users record.
One small platform difference: when a source has several audio tracks, iOS mixes them while Android Media3 picks one.
Try it
The fastest way to get a feel for it is the example app in the repo. It covers video picking, compression controls, metadata, thumbnails, cancellation and saving to the gallery.
Documentation: mkamran.dev/react-native-media-transformer
Source: github.com/muhammadkamran8586/react-native-media-transformer
Package: npm
Questions and bugs go in the GitHub issues, and the repo has a contributor guide if you want to send a pull request.
Enjoyed this article?
Check out more of my content or get in touch if you'd like to work together on your next project.


