NPM registry: expo-video-encoder
Encode a sequence of JPEG frames into an H.264 MP4, natively on iOS (AVFoundation) and Android (MediaCodec). Zero external dependencies. No binaries to download. No servers.
ffmpeg-kit-react-native is dead. The project was archived in late 2023 and every release binary on GitHub returns 404. If you've been building video export for a React Native iOS app, you already know this pain.
The alternatives people try:
| Option | Problem |
|---|---|
ffmpeg-kit-react-native |
Archived. All binaries 404. |
| Server-side encoding | Requires internet. Privacy risk. Adds latency. |
| FFmpeg WASM | Doesn't run on Hermes (React Native's JS engine). |
| Other RN packages | All depend on the same dead arthenica binaries. |
| Writing AVFoundation yourself | This is that, already written for you. |
Apple ships a fully capable video encoder in every iPhone and iPad called AVFoundation. It handles H.264 encoding in hardware, supports audio mixing, and has been stable since iOS 4. This package exposes it to React Native through a lean Expo native module.
- H.264 MP4 encoding: industry-standard format, plays everywhere
- Cross-platform: frame encoding runs on iOS (AVFoundation) and Android (MediaCodec) behind one API
- Frame-by-frame assembly: snapshot your canvas, Skia surface, or any pixel source
- Encode progress: an optional
onProgresscallback reports how many frames have been processed, on iOS and Android - Cancellation: pass an
AbortSignalto stop an encode that is running, on iOS and Android, without leaving a partial MP4 behind - Audio mixing: place up to 16 non-overlapping audio clips on the timeline, each with its own start time and volume (iOS today, see Platform support)
- Hardware accelerated: uses the device's built-in video encoder chip on both platforms
- Zero external dependencies: no CocoaPods binary downloads, no xcframework, no surprises
- Expo autolinking: install and it works, no manual native setup
- iOS 13.4+ and Android 7.0+ (API 24): covers virtually all devices in the wild today
- TypeScript first: full type definitions included
Understanding the pipeline helps you use it correctly and debug when something goes wrong.
JPEG files on disk
│
▼
UIImage (decoded)
│
▼
CVPixelBuffer (ARGB) ← one per frame
│
▼
AVAssetWriterInputPixelBufferAdaptor
│ appends each buffer at presentation timestamp
▼
AVAssetWriterInput (H.264, libx264 via VideoToolbox)
│
▼
AVAssetWriter → output.mp4
Each frame is:
- Read from disk as a JPEG
- Decoded into a
UIImage - Drawn into a
CVPixelBufferviaCGContext - Appended to the
AVAssetWriterInputPixelBufferAdaptorat its presentation timestamp (frame_index / fps)
The AVAssetWriter session is kept open across all frames, then finalized with markAsFinished() + finishWriting().
On Android the same pipeline runs through MediaCodec (an H.264 encoder configured with COLOR_FormatYUV420Flexible) feeding a MediaMuxer. Each JPEG is decoded to a Bitmap, converted to YUV, and queued as an input buffer with an explicit presentation timestamp of frame_index / fps; encoded samples are drained to the muxer until end of stream. The public API and the output MP4 are identical to iOS.
Silent MP4 ──────────────────┐
▼
Audio track A (narration) → AVMutableComposition
Audio track B (music) → AVMutableComposition
│
▼
AVAssetExportSession
(AVAssetExportPresetHighestQuality)
│
▼
mixed.mp4
Each audio track is inserted into an AVMutableComposition at its specified millisecond offset. The composition is then exported with AVAssetExportSession, which handles resampling, mixing, and rendering.
JPEG is the most practical format for frame transfer between JavaScript and native code in React Native:
@shopify/react-native-skiaand most canvas libraries can snapshot to JPEG base64expo-file-systemcan write base64 to disk in one call- JPEG decode on iOS is hardware-accelerated
- Raw pixel arrays (RGBA) would be 4 to 10x larger to transfer across the JS bridge
This package ships native code, so it does not run inside Expo Go. Use an Expo development build, a prebuilt Expo app, or a bare React Native app.
npx expo install expo-video-encoder
npx expo prebuildnpx expo run:ios and npx expo run:android also prebuild for you. No app.json config plugin is needed: Expo autolinking reads expo-module.config.json and wires up the native module on iOS and Android. For EAS Build, create a new development or production build after installing, because an over-the-air update cannot add native code.
The module is built on the Expo Modules API, so a bare app needs Expo modules support first. If your app does not have it yet:
npx install-expo-modules@latestThen install the package and the iOS pods:
npm install expo-video-encoder
npx pod-installAndroid picks the module up through autolinking on the next Gradle build.
| Requirement | Minimum | Source |
|---|---|---|
expo |
51 | peerDependencies |
react-native |
0.74 | peerDependencies |
| iOS | 13.4 | ExpoVideoEncoder.podspec |
| Android | API 24 (7.0) | android/build.gradle default minSdkVersion |
Note:
encodeVideoruns on iOS and Android.mixAudiois iOS only for now (see Platform support); on Android it throws, and since audio mixing is designed to be non-fatal, callers should fall back to the silent video. On any non-mobile platform (web) both functions throw a clear error.
| Function | iOS | Android | Native backend |
|---|---|---|---|
encodeVideo |
✅ 13.4+ | ✅ 7.0+ (API 24) | AVFoundation (iOS), MediaCodec + MediaMuxer (Android) |
mixAudio |
✅ 13.4+ | ⬜ not yet | AVMutableComposition + AVAssetExportSession (iOS) |
Android audio mixing is tracked as a follow-up. Because mixAudio failures are expected to be non-fatal, the recommended pattern already falls back to the silent video when it throws, so Android apps keep working with export-without-audio today. See CONTRIBUTING.md if you want to help build it.
The minimum viable video export:
import * as FileSystem from 'expo-file-system';
import * as MediaLibrary from 'expo-media-library';
import { encodeVideo, toNativePath } from 'expo-video-encoder';
async function exportVideo() {
const framesDir = `${FileSystem.cacheDirectory}frames/`;
const outputPath = `${FileSystem.cacheDirectory}output.mp4`;
// 1. Create the frames directory
await FileSystem.makeDirectoryAsync(framesDir, { intermediates: true });
// 2. Write your frames as frame_000000.jpg, frame_000001.jpg, …
// (see "Capturing frames" section below for how to do this with Skia)
// 3. Encode
await encodeVideo({
framesDir: toNativePath(framesDir),
frameCount: 60, // number of frames you wrote
fps: 30,
width: 1920,
height: 1080,
outputPath: toNativePath(outputPath),
});
// 4. Save to Photos library
await MediaLibrary.createAssetAsync(outputPath);
}Skia encodes PNG when encodeToBytes() or encodeToBase64() is called without arguments, so pass ImageFormat.JPEG explicitly:
import { ImageFormat, useCanvasRef } from '@shopify/react-native-skia';
import { File, type Directory } from 'expo-file-system';
import { frameFileName } from 'expo-video-encoder';
const ref = useCanvasRef();
async function captureFrame(framesDir: Directory, index: number): Promise<void> {
const image = await ref.current?.makeImageSnapshotAsync();
if (!image) throw new Error('Snapshot failed');
new File(framesDir, frameFileName(index)).write(image.encodeToBytes(ImageFormat.JPEG, 90));
}Then in your frame loop:
for (let i = 0; i < totalFrames; i++) {
// seek your animation to frame i / fps seconds
seekTo(i / fps);
await new Promise(r => requestAnimationFrame(r)); // let Skia render
await captureFrame(framesDir, i);
}If your app writes frames as base64 strings with the legacy expo-file-system API (as in the complete export example below), use image.encodeToBase64(ImageFormat.JPEG, 90) instead.
import { GLView } from 'expo-gl';
import * as FileSystem from 'expo-file-system';
async function captureGLFrame(gl: WebGLRenderingContext): Promise<string> {
const { width, height } = gl.drawingBufferWidth
? { width: gl.drawingBufferWidth, height: gl.drawingBufferHeight }
: { width: 1920, height: 1080 };
const pixels = new Uint8Array(width * height * 4);
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
// convert to base64 JPEG before writing…
}Everything below is exported from expo-video-encoder:
| Export | Kind | Platforms |
|---|---|---|
encodeVideo |
async function | iOS, Android |
mixAudio |
async function | iOS |
isAudioMixSupported |
function | any (returns false off iOS) |
frameFileName, frameFilePath, toNativePath |
functions | any (pure JavaScript) |
findMissingFrames |
async function | any (you supply the file check) |
ExpoVideoEncoderError, isExpoVideoEncoderError |
error class and type guard | any |
EncodeVideoOptions, EncodeVideoProgressOptions, EncodeProgress, EncodeAbortSignal, MixAudioOptions, AudioTrack, FrameExistsCheck, ExpoVideoEncoderErrorCode, ExpoVideoEncoderNativeErrorCode |
types |
type EncodeVideoOptions = {
framesDir: string;
frameCount: number;
fps: number;
width: number;
height: number;
outputPath: string;
};
type EncodeProgress = {
processedFrames: number;
encodedFrames: number;
frameCount: number;
progress: number;
};
type EncodeAbortSignal = {
readonly aborted: boolean;
readonly reason?: unknown;
addEventListener: (type: 'abort', listener: () => void) => void;
removeEventListener: (type: 'abort', listener: () => void) => void;
};
type EncodeVideoProgressOptions = {
onProgress?: (progress: EncodeProgress) => void;
signal?: EncodeAbortSignal;
};
type AudioTrack = {
uri: string;
startMs: number;
durationMs: number;
volume: number;
};
type MixAudioOptions = {
videoPath: string;
audioTracks: AudioTrack[];
outputPath: string;
totalDurationMs: number;
};
type FrameExistsCheck = (path: string, index: number) => boolean | Promise<boolean>;
type ExpoVideoEncoderNativeErrorCode =
| 'NO_READABLE_FRAMES'
| 'WRITER_FAILED'
| 'ENCODE_CANCELLED'
| 'ENCODE_ERROR'
| 'MIX_ERROR'
| 'MIX_UNSUPPORTED'
| 'INVALID_ARGS';
type ExpoVideoEncoderErrorCode =
| 'INVALID_OPTIONS'
| 'INVALID_ARGUMENT'
| 'UNSUPPORTED_PLATFORM'
| ExpoVideoEncoderNativeErrorCode;
class ExpoVideoEncoderError extends Error {
readonly code: ExpoVideoEncoderErrorCode;
readonly field: string | undefined;
readonly cause: unknown;
}
function encodeVideo(options: EncodeVideoOptions, progressOptions?: EncodeVideoProgressOptions): Promise<boolean>;
function mixAudio(options: MixAudioOptions): Promise<boolean>;
function isAudioMixSupported(): boolean;
function frameFileName(index: number): string;
function frameFilePath(framesDir: string, index: number): string;
function toNativePath(uri: string): string;
function findMissingFrames(framesDir: string, frameCount: number, frameExists: FrameExistsCheck): Promise<number[]>;
function isExpoVideoEncoderError(error: unknown, code?: ExpoVideoEncoderErrorCode): error is ExpoVideoEncoderError;Every path option (framesDir, outputPath, videoPath, and AudioTrack.uri) must be a plain absolute native path. It must start with /, must not start with file://, must not have leading or trailing whitespace, and must not contain . or .. segments. Convert paths from expo-file-system with toNativePath first. Path comparisons (output inside framesDir, output equal to input, duplicate audio files) ignore letter case and trailing slashes, so the checks stay conservative on case-insensitive file systems.
encodeVideo(options: EncodeVideoOptions, progressOptions?: EncodeVideoProgressOptions): Promise<boolean>
Assembles a directory of JPEG frames into an H.264 MP4 file. Resolves true on success.
| Option | Type | Rules checked before native work |
|---|---|---|
framesDir |
string |
Native path (see above). Must be a directory, so it must not end in .jpg or .jpeg. |
frameCount |
number |
Positive integer. frameCount / fps must be one hour (3600000 ms) or less. |
fps |
number |
Finite number from 1 to 240. |
width |
number |
Even integer from 2 to 8192. |
height |
number |
Even integer from 2 to 8192. |
outputPath |
string |
Native path ending in .mp4 (any case). Must be outside framesDir. |
Behaviour:
- Frames are read as
framesDir/frame_000000.jpgup toframe_{frameCount - 1}(six digit zero padding). UseframeFileName(i)orframeFilePath(dir, i)to produce those names. - Frame
iis shown ati / fpsseconds. Missing or unreadable frames are skipped without an error, but at least one readable frame is required, otherwise the promise rejects withNO_READABLE_FRAMES. A skipped frame is not replaced: the other frames keep theiri / fpstimestamps, and missing frames at the end make the video shorter. RunfindMissingFramesfirst if a partial capture should fail the export (see Checking frames before encoding). - Each frame is scaled to
widthxheight. Aspect ratio is not preserved, so capture frames at the output size or the same aspect ratio. - An existing file at
outputPathis replaced, and missing parent folders ofoutputPathare created. On Android, a failed encode deletes the partial file atoutputPath. - Fractional
fpssuch as29.97is kept on both platforms. iOS places frames on a 90 kHz clock and Android roundsi / fpsto the nearest microsecond, so 60 frames at29.97last 2.002 seconds on either.
Pass { onProgress } as the second argument to follow a long encode:
await encodeVideo(options, {
onProgress: ({ processedFrames, frameCount, progress }) => {
setLabel(`Encoding ${processedFrames} of ${frameCount}`);
setBar(progress);
},
});EncodeProgress field |
Meaning |
|---|---|
processedFrames |
Frame files handled so far, from 1 to frameCount. Skipped (missing or unreadable) frames count as handled. |
encodedFrames |
Frames actually handed to the encoder so far. Lower than processedFrames when frames were skipped. |
frameCount |
The frameCount you passed. |
progress |
processedFrames / frameCount, from 0 to 1. |
- The native encoder sends at most one event per whole percent, plus one for the last frame, so a 100000 frame encode produces about 100 callbacks, and an encode of fewer than 100 frames produces one per frame.
progressreaching 1 means every frame has been read and queued. The MP4 is still being finished after that; the promise resolving is the completion signal.- Events arrive asynchronously from the native thread. The listener is removed as soon as the promise settles, so an event still in flight at that moment is dropped. Do not rely on seeing a final
progress: 1beforeawait encodeVideoreturns. - Each call gets its own job id, so concurrent encodes only see their own progress.
- Without
onProgressnothing changes: no listener is added and the native side sends no events. - A second argument that is not an object, or an
onProgressthat is not a function, rejects withINVALID_ARGUMENTbefore any native work. - The callback runs on the JavaScript thread. An error it throws is not caught by
encodeVideo. - Progress needs the native code from 1.1.0 or later. With newer JavaScript on an older native build (for example an over the air update),
encodeVideostill works andonProgressis simply never called.
Pass an AbortSignal as signal in the same second argument, and call abort() to stop the encode, for example when the user leaves the export screen:
const controller = new AbortController();
const cancelExport = () => controller.abort();
try {
await encodeVideo(options, { signal: controller.signal, onProgress });
} catch (error) {
if (isExpoVideoEncoderError(error, 'ENCODE_CANCELLED')) return;
throw error;
}Wire cancelExport to a cancel button or to the screen's unmount cleanup.
- The native encoder checks for cancellation before it touches
outputPath, before every frame, and before it finishes the MP4 (on iOS also while it waits for the writer to accept a frame). When it sees the request it stops, deletes the partial file atoutputPath, and the promise rejects withENCODE_CANCELLED. - If the signal is already aborted when
encodeVideois called, it rejects withENCODE_CANCELLEDbefore any native work, withsignal.reasonascause. Option errors are reported first. - If the abort arrives before the native side started the encode, an existing file at
outputPathis left as it was. Once the encode has started, the old file has already been replaced, so a cancelled encode leaves no file atoutputPath. - If the abort arrives after the MP4 has been finished, it is too late to cancel and
encodeVideoresolvestrueas usual. - Each call cancels only its own encode, so aborting one of several concurrent encodes leaves the others running.
- Any object with a boolean
abortedandaddEventListenerandremoveEventListenermethods is accepted, which covers theAbortControllerbuilt into React Native. Anything else rejects withINVALID_ARGUMENTbefore any native work. - Cancellation needs the native code from 1.2.0 or later. With newer JavaScript on an older native build (for example an over the air update), an abort during the encode is ignored and the encode runs to the end. An already aborted signal is still rejected in JavaScript.
mixAudiocannot be cancelled yet.
Mixes audio clips onto an existing silent MP4 and writes a new MP4. Resolves true on success.
iOS only for now. On other platforms it rejects with
UNSUPPORTED_PLATFORMbefore any native call. CheckisAudioMixSupported()first, and treat any failure as non-fatal by falling back to the silent video (the pipeline example below does this).
| Option | Type | Rules checked before native work |
|---|---|---|
videoPath |
string |
Native path ending in .mp4. |
audioTracks |
AudioTrack[] |
1 to 16 tracks. Tracks must not overlap in time (back to back is fine) and must use different files. |
outputPath |
string |
Native path ending in .mp4. Must be different from videoPath. An existing file is replaced. |
totalDurationMs |
number |
Positive integer, 3600000 (one hour) or less. Sets the export time range. |
| Field | Type | Rules checked before native work |
|---|---|---|
uri |
string |
Native path (despite the name, file:// URIs are rejected) ending in .aac, .caf, .m4a, .mp3, or .wav (any case). |
startMs |
number |
Integer, 0 or greater. Where the clip starts in the output. |
durationMs |
number |
Positive integer. How much of the clip to use, from its start. startMs + durationMs must be totalDurationMs or less. |
volume |
number |
From 0 (silent) to 1 (full). |
On iOS, a track whose file has no readable audio is skipped rather than failing the export.
true where mixAudio is implemented (iOS today). Use it to skip the audio step instead of catching an error.
| Function | Returns | Description |
|---|---|---|
frameFileName(index) |
string |
The file name the encoder reads for a frame, e.g. frameFileName(7) is frame_000007.jpg. Throws INVALID_ARGUMENT for anything but an integer from 0 to 999999. |
frameFilePath(framesDir, index) |
string |
framesDir (plain path or file:// URI) joined with frameFileName(index), as a native path. Trailing slashes on framesDir are ignored. |
toNativePath(uri) |
string |
Trims whitespace, strips file:// (and file://localhost), decodes percent-encoding, and returns a plain absolute path. Plain paths pass through trimmed. Throws INVALID_ARGUMENT for an empty string or broken percent-encoding. |
findMissingFrames(framesDir, frameCount, frameExists) |
Promise<number[]> |
Calls frameExists(path, index) for every frame from 0 to frameCount - 1 (32 checks at a time) and resolves the indexes that returned false, in ascending order. path is the native path the encoder will read. Rejects with INVALID_ARGUMENT if frameCount is not an integer from 1 to 1000000, if frameExists is not a function, or if it returns anything other than a boolean (for example a whole file info object). Errors thrown by frameExists are passed through. |
The native encoders skip a frame file that is missing or cannot be decoded and keep going, so a capture loop that failed halfway still produces a video. findMissingFrames lets you catch that in JavaScript first. The package has no file system dependency, so you pass the existence check. It only checks that each file exists, not that it is a valid JPEG.
import * as FileSystem from 'expo-file-system';
import { encodeVideo, findMissingFrames, frameFileName, toNativePath } from 'expo-video-encoder';
const framesDir = `${FileSystem.cacheDirectory}frames/`;
const missing = await findMissingFrames(framesDir, frameCount, async (_path, index) => {
const info = await FileSystem.getInfoAsync(`${framesDir}${frameFileName(index)}`);
return info.exists;
});
if (missing.length > 0) {
throw new Error(`Frames not written: ${missing.slice(0, 10).join(', ')}`);
}
await encodeVideo({ framesDir: toNativePath(framesDir), frameCount, fps, width, height, outputPath });Problems this package can detect in JavaScript are thrown (or, from the async functions, rejected) as ExpoVideoEncoderError before any native work starts:
code |
When | field |
|---|---|---|
INVALID_OPTIONS |
An encodeVideo or mixAudio option breaks a rule listed above. |
The option path, such as 'fps', 'outputPath', or 'audioTracks[1].volume'. undefined when the options value itself is not an object. |
INVALID_ARGUMENT |
A path or frame helper (including findMissingFrames) received a value it cannot use, or the encodeVideo second argument is not { onProgress?: function, signal?: AbortSignal }. |
undefined |
UNSUPPORTED_PLATFORM |
encodeVideo off iOS and Android, or mixAudio off iOS. |
undefined |
Failures inside the native modules are also rejected as ExpoVideoEncoderError, with the native code kept as code and the original Expo Modules error as cause:
code |
When |
|---|---|
NO_READABLE_FRAMES |
encodeVideo found none of the frameCount frame files, or none decoded as JPEG. No output file is left behind. |
WRITER_FAILED |
The MP4 could not be written. On iOS, AVAssetWriter could not be created, refused the H.264 settings, failed to start, stopped during encoding (for example when the disk is full), or could not finish the file, and the message includes the reason AVAssetWriter gave. On Android, MediaMuxer could not open outputPath, add the video track, write a frame, or finish the file, or the encoder produced no frames; the message names the step that failed. |
ENCODE_CANCELLED |
encodeVideo was cancelled through its signal (see Cancelling). No partial file is left at outputPath. |
ENCODE_ERROR |
Any other encoding failure, for example an Android MediaCodec error or an existing outputPath that could not be replaced. |
MIX_ERROR |
iOS audio mixing failed, for example the video has no video track or the export failed. |
MIX_UNSUPPORTED |
The Android native module was called for mixAudio directly. The JavaScript API rejects with UNSUPPORTED_PLATFORM first. |
INVALID_ARGS |
The native side could not read the options (normally caught earlier by INVALID_OPTIONS). |
Errors from the native layer that do not carry one of these codes are passed through unchanged.
import { encodeVideo, isExpoVideoEncoderError } from 'expo-video-encoder';
try {
await encodeVideo(options);
} catch (error) {
if (isExpoVideoEncoderError(error, 'INVALID_OPTIONS')) {
console.warn(`Fix ${error.field}: ${error.message}`);
} else if (isExpoVideoEncoderError(error, 'NO_READABLE_FRAMES')) {
console.warn('No frames were captured, nothing to encode.');
} else {
throw error;
}
}Error messages are meant for developers and may be reworded in minor releases. Match on code and field, not on message text.
- Input is JPEG files on disk with the fixed
frame_000000.jpgnaming. There is no in-memory or PNG input. - Output is H.264 in MP4 only. The bitrate is derived from
width * height * fps / 8and cannot be configured yet. - Only
encodeVideocan be cancelled, notmixAudio. Progress is reported per frame read (see Progress), not for finishing the MP4 file or formixAudio. - Audio mixing is iOS only, and overlapping clips (for example narration over music) are rejected. Mix overlapping audio into one file first.
- Web and other platforms are not supported.
- Android encoding has been verified on an emulator (see the 1.1.0 notes in CHANGELOG.md), not yet on a physical Android device. CI covers the JavaScript layer and package contents, not native builds.
A production-ready export flow with progress reporting:
import * as FileSystem from 'expo-file-system';
import * as MediaLibrary from 'expo-media-library';
import * as Sharing from 'expo-sharing';
import { encodeVideo, frameFileName, isAudioMixSupported, mixAudio, toNativePath } from 'expo-video-encoder';
type ExportOptions = {
frameCount: number;
fps: number;
width: number;
height: number;
captureFrame: (frameIndex: number) => Promise<string>; // returns JPEG base64
audioTracks?: {
uri: string;
startMs: number;
durationMs: number;
volume: number;
}[];
totalDurationMs: number;
saveToLibrary: boolean;
onProgress: (phase: string, percent: number) => void;
};
export async function runExport(options: ExportOptions): Promise<string | null> {
const {
frameCount, fps, width, height,
captureFrame, audioTracks = [],
totalDurationMs, saveToLibrary, onProgress,
} = options;
const exportId = `export_${Date.now()}`;
const tempDir = `${FileSystem.cacheDirectory}${exportId}/`;
const framesDir = `${tempDir}frames/`;
const silentMp4 = `${tempDir}silent.mp4`;
const mixedMp4 = `${tempDir}mixed.mp4`;
// ── 1. Prepare ──────────────────────────────────────────────────────────────
onProgress('Preparing', 0);
await FileSystem.makeDirectoryAsync(framesDir, { intermediates: true });
// ── 2. Capture frames ───────────────────────────────────────────────────────
for (let i = 0; i < frameCount; i++) {
onProgress('Capturing frames', i / frameCount);
const base64 = await captureFrame(i);
await FileSystem.writeAsStringAsync(`${framesDir}${frameFileName(i)}`, base64, {
encoding: FileSystem.EncodingType.Base64,
});
}
// ── 3. Encode ───────────────────────────────────────────────────────────────
onProgress('Encoding video', 0);
await encodeVideo(
{
framesDir: toNativePath(framesDir),
frameCount,
fps,
width,
height,
outputPath: toNativePath(silentMp4),
},
{ onProgress: (encode) => onProgress('Encoding video', encode.progress) },
);
onProgress('Encoding video', 1);
// ── 4. Mix audio (non-fatal) ─────────────────────────────────────────────
let finalPath = silentMp4;
if (audioTracks.length > 0 && isAudioMixSupported()) {
onProgress('Mixing audio', 0);
try {
await mixAudio({
videoPath: toNativePath(silentMp4),
audioTracks,
outputPath: toNativePath(mixedMp4),
totalDurationMs,
});
finalPath = mixedMp4;
} catch {
// audio mix failed, continue with silent video
}
onProgress('Mixing audio', 1);
}
// ── 5. Save ─────────────────────────────────────────────────────────────────
onProgress('Saving', 0);
let outputUri: string | null = null;
if (saveToLibrary) {
const asset = await MediaLibrary.createAssetAsync(finalPath);
outputUri = asset.uri;
} else {
await Sharing.shareAsync(finalPath, { mimeType: 'video/mp4' });
outputUri = finalPath;
}
// ── 6. Cleanup ───────────────────────────────────────────────────────────────
await FileSystem.deleteAsync(framesDir, { idempotent: true });
if (finalPath !== silentMp4) {
await FileSystem.deleteAsync(silentMp4, { idempotent: true });
}
onProgress('Done', 1);
return outputUri;
}example/ is a runnable Expo app. It draws 60 JPEG frames in JavaScript, checks them with findMissingFrames, encodes a 2 second MP4 with encodeVideo, mixes in a generated tone with mixAudio on iOS, plays the result, and shows the typed error for an invalid width. The encoding code is in example/src/pipeline.ts.
npm ci
cd example && npm ci
npm run ios # or: npm run androidIt needs a development build, because Expo Go cannot load this module. See CONTRIBUTING.md for details.
React Native's expo-file-system returns paths with a file:// prefix (e.g. file:///var/mobile/…), and folder names with spaces arrive percent-encoded (My%20Clips). The native encoders expect plain filesystem paths. Convert with toNativePath before passing a path to this module:
import { toNativePath } from 'expo-video-encoder';
const outputPath = toNativePath(FileSystem.cacheDirectory + 'export.mp4');A bare uri.replace(/^file:\/\//, '') strips the prefix but leaves %20 in place, which points at a folder that does not exist. This is the most common source of "file not found" errors.
Use the device's resolution, not higher. Encoding at 4K on a device with a 2K screen wastes time and produces imperceptibly better output. Match your canvas size.
Keep frames on disk, not in memory. The JPEG → disk → native pipeline is intentional. Passing large base64 strings through the JS bridge for every frame would be slower and more memory-intensive.
Audio mixing is a second pass. mixAudio reads the silent MP4 and the audio files, mixes them, and writes a new file. Keep totalDurationMs accurate. If it is longer than the video, the export session will pad with silence.
Frame capture is usually the bottleneck. The encoding step is hardware-accelerated and fast. The slow part is typically your canvas render + snapshot loop. Optimize there first.
"File not found" during encode
→ You passed a file:// URI. Convert it with toNativePath(path).
"No video track in source file"
→ encodeVideo failed silently and you called mixAudio on a corrupt/empty file. Check that encodeVideo resolved true before calling mixAudio.
Video is shorter than expected, or freezes on one frame
→ Some frame files were missing or unreadable and the encoder skipped them. Run findMissingFrames before encodeVideo to list the missing indexes.
Frames appear in wrong order
→ Frame files must be named with zero-padded numbers: frame_000000.jpg, not frame_0.jpg. Use frameFileName(i).
Black frames in output
→ Your canvas wasn't done rendering when you took the snapshot. Add await new Promise(r => requestAnimationFrame(r)) before each snapshot.
Module not found after install
→ Run npx expo prebuild to regenerate the native project so autolinking can wire up the module.
iOS: "Cannot find native module 'VideoEncoder'"
→ Versions up to 1.0.23 keep the podspec at the package root without telling Expo autolinking where it is, so the pod is installed but the module is never registered on iOS. Upgrade to 1.1.0 or later, then run npx expo prebuild --clean (or pod install in bare projects).
Build error: "No such module ExpoModulesCore"
→ ExpoModulesCore is a peer dependency. Make sure expo is installed and npx expo prebuild has been run.
- Android frame encoding via
MediaCodec+MediaMuxer(encodeVideo) - Android audio mixing (
mixAudio) viaMediaExtractor+MediaMuxer - Progress callbacks: per-frame encode progress from native to JS (
encodeVideo(options, { onProgress })) - Cancellation: stop a running encode with an
AbortSignal(encodeVideo(options, { signal })) - Quality presets: CRF control for file size vs. quality tradeoff
- HEVC / H.265: smaller files at the same quality on iOS 11+
- Frame timestamp control: variable frame rate support
Want to contribute? Android audio mixing is now the highest-impact next step. See CONTRIBUTING.md.
-
llms.txtandllms-full.txt: the full API reference and recipes in plain markdown, for pasting into a prompt or pointing a tool at. -
AGENTS.md: install, imports, the option vocabulary, recipes and pitfalls. -
A Claude Code skill. Install it in a project with:
mkdir -p .claude/skills/expo-video-encoder curl -fsSL https://raw.githubusercontent.com/ajibadedapo/expo-video-encoder/main/skills/expo-video-encoder/SKILL.md -o .claude/skills/expo-video-encoder/SKILL.md
Or copy
skills/expo-video-encoderinto~/.claude/skills/to use it in every project.
Contributions are welcome. Because you cannot push to this repo directly, contributions go through a fork and a pull request, see CONTRIBUTING.md for the step-by-step flow, local setup, and the PR checklist. Android support via MediaCodec is the highest-impact place to start.
This project follows the Contributor Covenant code of conduct. Security issues should be reported privately, see SECURITY.md.
See CHANGELOG.md.
MIT © AJIBADE HAMMED ADEDAPO