Works with Expo development builds • Read the Documentation • Report Issues
munim-ffmpeg provides fast, typed FFmpeg and FFprobe access for Expo and React Native. It executes argument arrays directly through native FFmpeg libraries, streams logs and encoding statistics to JavaScript, returns structured session results, inspects media with FFprobe, and cancels one running session or every active session.
Built with React Native Nitro Modules. One TypeScript specification generates the C++, Swift, and Kotlin bindings used by the package.
Designed for Expo development builds and bare React Native. This package contains native code and cannot run in Expo Go.
Licensing note: The JavaScript, Swift, Kotlin, and Nitro bridge are Apache-2.0. The bundled FFmpeg is LGPLv3 on both platforms — it deliberately excludes x264, x265, and xvid, so your app does not inherit GPL obligations. See Licensing.
libmunimffmpeg.so per Android ABI and one static library in the iOS xcframework, so nothing else has to be linked, loaded, or packagedlibmunimffmpeg.so and the Nitro bridge) are linked with 16 KB LOAD alignment, as Google Play requires for 64-bit apps from 1 February 2027| Capability | iOS | Android | Notes |
|---|---|---|---|
| FFmpeg argument execution | ✅ | ✅ | Commands run asynchronously through the native compatibility library. |
| FFprobe argument execution | ✅ | ✅ | Custom FFprobe arguments return FFmpegSessionResult. |
| Parsed media information | ✅ | ✅ | getMediaInformation() returns typed, parsed FFprobe JSON. |
| Log callback | ✅ | ✅ | Logs are delivered while a session is active. |
| Encoding-statistics callback | ✅ | ✅ | Available for FFmpeg execution. |
| Immediate session ID | ✅ | ✅ | onSessionCreated fires after the native session is created. |
| Cancel one FFmpeg session | ✅ | ✅ | Pass the positive safe-integer ID received by execute's onSessionCreated. The native dependency does not expose FFprobe cancellation. |
| Cancel all FFmpeg sessions | ✅ | ✅ | Use cancelAll() or call cancel() without an ID. |
| Pause and resume a session | ✅ | ✅ | pause() / resume() with an execute session ID, running or queued. FFprobe sessions cannot be paused. |
| Session state | ✅ | ✅ | getSessionState() reports queued, running, paused, completed, failed, or cancelled. |
| Expo Go | ❌ | ❌ | A native development build is required. |
| Capability discovery | ✅ | ✅ | listEncoders(), listDecoders(), listMuxers(), listDemuxers(), listFilters(), listProtocols(), and pickEncoder() report what the bundled build supports. |
| Subtitle burn-in | ✅ | ✅ | libass with system fonts: Core Text on iOS, fontconfig over /system/fonts on Android. |
| H.264 encoding | VideoToolbox | MediaCodec | Hardware on both, libopenh264 as the software fallback; use pickEncoder(['h264_videotoolbox', 'h264_mediacodec', 'libopenh264']) instead of hard-coding an encoder. |
| Remote HTTP(S) inputs | ✅ | ✅ | iOS links SecureTransport, Android links mbedTLS. Remote server behaviour still varies; prefer local files for predictable app workflows. |
| Soft subtitle embedding | ✅ | ✅ | Mux SRT/ASS as toggleable tracks (MKV: srt/ass, MP4: mov_text). |
| AVIF / AV1 encoding | ✅ | ✅ | libaom-av1 encodes (add -still-picture 1 -f avif for images); libdav1d decodes. |
Codec availability is determined by the native FFmpeg builds described in Bundled FFmpeg builds. Do not assume every FFmpeg codec or external library is present.
Every release runs the example's device suite (33 checks, including pause/resume). For 0.8.x:
| Target | Result |
|---|---|
| iPad Air (M3), iPadOS 27 | 33/33 |
| Galaxy A14 5G, arm64-v8a | 33/33 |
| Android emulator, arm64 | Software encoding passes; hardware encoding does not — see below |
Android emulator, x86_64 |
Same: libopenh264 passes, MediaCodec does not |
Android armeabi-v7a |
Built and statically checked, not executed |
x86_64 was verified on an Intel Windows machine, since the Android emulator refuses non-native system images on Apple Silicon: FFmpeg 9.0.1, 185 encoders, and the software H.264 encode passes. armeabi-v7a has no hardware to hand, so it was checked statically instead — correct ELF architecture, the expected JNI exports, only system libraries unresolved, and the same FFmpeg and codec set as arm64.
An Android emulator has no working MediaCodec encoder. h264_mediacodec and hevc_mediacodec report success and produce a file containing no frames, so anything downstream of an encode fails. Everything else — audio encoding, filters, FFprobe, muxing, cancellation, protocols — works normally there.
This is an emulator limitation, not a package one, but it is worth knowing before debugging: test video encoding on a physical device.
pickEncoder cannot detect it, because MediaCodec is present in an emulator — it just does not work. When you need encoding to succeed regardless of environment, ask for the software encoder by name:
// Deterministic anywhere: emulators, CI, older devices.
await execute([
'-y',
'-i',
input,
'-c:v',
'libopenh264',
'-pix_fmt',
'yuv420p',
output,
])
libopenh264, mpeg4 and libvpx-vp9 are all software encoders and work everywhere.
Both platforms run FFmpeg 9.0.2, built from ffmpeg.org by the scripts in scripts/ffmpeg/. There is no FFmpegKit here: that project was retired in 2025 and pinned to FFmpeg 6.0.
| iOS | Android | |
|---|---|---|
| FFmpeg | 9.0.2 | 9.0.2 |
| Architectures | arm64 device, arm64 + x86_64 simulator | arm64-v8a, armeabi-v7a, x86_64 |
| Hardware codecs | VideoToolbox, AudioToolbox | MediaCodec |
| TLS | SecureTransport | mbedTLS |
| Minimum | iOS 15.1 | API 24, 16 KB pages |
| Ships as | MunimFFmpeg.xcframework, one static library per slice |
one libmunimffmpeg.so per ABI |
Linked libraries, identical on both: LAME (MP3), Opus, libvpx (VP8/VP9), dav1d (AV1 decoding), libaom (AV1 and AVIF encoding), openh264 (software H.264), libass with FreeType, HarfBuzz, and FriBidi (subtitle rendering and text shaping), plus everything FFmpeg builds natively. Android additionally links fontconfig and expat so libass can discover the system fonts; iOS uses Core Text for the same job.
FFmpeg's own ffmpeg and ffprobe tools are compiled to run inside your app process, so the argument arrays you pass are handled by the real command-line code paths rather than a reimplementation.
Everything above is linked statically into a single library per platform: FFmpeg, both tools, the core, and every external library. On Android that is one libmunimffmpeg.so per ABI whose only dependencies are the system libraries and the libc++_shared.so React Native already bundles; on iOS it is one static library per slice inside MunimFFmpeg.xcframework. Only the JNI entry points are exported on Android, so the bundled FFmpeg cannot collide with another copy an app might carry.
Verified by running the example's device suite: iOS reports 187 encoders, Android 185. Everything FFmpeg builds natively (aac, alac, flac, mpeg4, mjpeg, png, gif, pcm_*, …) is on both, as are libmp3lame, libopus, libvpx, and libvpx-vp9.
H.264 and HEVC come from the platform's hardware encoder, which is faster and uses less power than a software encoder. libopenh264 is there as a software H.264 fallback for anywhere hardware encoding is unavailable — an emulator, for instance:
| Encoder | iOS | Android |
|---|---|---|
h264_videotoolbox, hevc_videotoolbox, prores_videotoolbox |
✅ | ❌ |
h264_mediacodec, hevc_mediacodec, vp8_mediacodec, vp9_mediacodec |
❌ | ✅ |
aac_at, alac_at (AudioToolbox) |
✅ | ❌ |
libopenh264 (H.264, software) |
✅ | ✅ |
libaom-av1 (AV1, software; AVIF stills) |
✅ | ✅ |
Resolve the name at runtime instead of branching on Platform.OS:
import { execute, pickEncoder } from 'munim-ffmpeg'
// Hardware first, software as the fallback.
const h264 = await pickEncoder([
'h264_videotoolbox',
'h264_mediacodec',
'libopenh264',
])
if (!h264) throw new Error('No H.264 encoder in this build')
await execute(['-y', '-i', inputPath, '-c:v', h264, outputPath])
Two things to know about hardware encoders: they want NV12 input on Android (-pix_fmt nv12) and planar YUV on iOS, and they reject very small frames — 176×144 is the smallest size that works everywhere.
Decoding is uniform: H.264, HEVC, VP8/VP9, AV1 (via dav1d, including AVIF images), MPEG-4, MP3, AAC, Vorbis, Opus, FLAC and the usual containers, on both platforms. Both link TLS, so https:// inputs work.
On iOS 26.2 and later the module also registers Apple's supplemental AV1 and VP9 decoders with VideoToolbox (VTRegisterSupplementalVideoDecoderIfAvailable) before the first run, as FFmpeg already does on macOS, so -hwaccel videotoolbox can use them on devices that have them. Without -hwaccel, decoding stays in software (dav1d for AV1, FFmpeg's own VP9 decoder) as before.
npm install munim-ffmpeg react-native-nitro-modules
# or
yarn add munim-ffmpeg react-native-nitro-modules
Install iOS pods after adding the dependency:
cd ios
pod install
cd ..
FFmpeg and React Native both provide libc++_shared.so. Resolve that duplicate in the Android application module:
android {
packagingOptions {
jniLibs {
pickFirsts += ['**/libc++_shared.so']
}
}
}
npx expo install munim-ffmpeg react-native-nitro-modules
The package includes an Expo config plugin. If your project manages its plugin list explicitly, add it to app.json:
{
"expo": {
"plugins": ["munim-ffmpeg"]
}
}
The plugin also configures Android to select one shared C++ runtime when React Native and FFmpeg contribute the same libc++_shared.so path.
Create a native development build after installation:
npx expo prebuild
npx expo run:ios
# or
npx expo run:android
You can also create an EAS development build.
Important:
munim-ffmpegcannot run in Expo Go because Expo Go does not include this package's native libraries.
The FFmpeg libraries are well over 100 MB across all six architectures, which does not belong in an npm tarball, so they are downloaded from the matching GitHub release when the package installs and verified against the checksum in scripts/binaries.json.
If your environment blocks install scripts (npm install --ignore-scripts), fetch them explicitly:
npx munim-ffmpeg-fetch-binaries
Behind a proxy or an air-gapped mirror, point the fetcher somewhere else:
MUNIM_FFMPEG_BINARIES_URL=https://internal.example.com/munim-ffmpeg-binaries.tar.gz \
npx munim-ffmpeg-fetch-binaries
Or build them yourself — see scripts/ffmpeg/README.md.
react-native-nitro-modules 0.36.5 or newerNo camera, microphone, photo-library, or storage permission is added automatically. Declare only the permissions required by the locations and capture APIs your application uses.
FFmpeg runs natively and needs a path or URI the native process can access.
file:// URIs and plain local paths both work. The package strips the file:// scheme and percent-decoding for you, so a path containing spaces or non-ASCII characters is handled correctly; passing the raw URI straight to FFmpeg would write to a file literally named my%20clip.mp4.content:// document into application storage before processing when the native library cannot open it directly.-y when replacing an existing file is intentional.ffmpeg or ffprobe executable name in the argument array.import {
cancel,
execute,
getFFmpegVersion,
getMediaInformation,
probe,
} from 'munim-ffmpeg'
console.log('FFmpeg:', getFFmpegVersion())
let activeSessionId: number | undefined
const execution = execute(
['-y', '-i', inputPath, '-c:v', 'mpeg4', '-c:a', 'aac', outputPath],
(message) => console.log(message),
(timeMs, sizeBytes, bitrateKbits, speed, frame, fps, quality) => {
console.log({
timeMs,
sizeBytes,
bitrateKbits,
speed,
frame,
fps,
quality,
})
},
(sessionId) => {
activeSessionId = sessionId
}
)
// Call this from a cancel button while the command is running.
if (activeSessionId !== undefined) {
cancel(activeSessionId)
}
const result = await execution
if (!result.success && !result.cancelled) {
throw new Error(result.failStackTrace ?? result.output)
}
const probeResult = await probe([
'-v',
'error',
'-show_format',
'-show_streams',
inputPath,
])
const mediaInformation = await getMediaInformation(inputPath)
execute(arguments, onLog?, onStatistics?, onSessionCreated?)Starts an asynchronous FFmpeg session.
function execute(
arguments_: string[],
onLog?: (message: string) => void,
onStatistics?: (
timeMs: number,
sizeBytes: number,
bitrateKbits: number,
speed: number,
videoFrameNumber: number,
fps: number,
quality: number
) => void,
onSessionCreated?: (sessionId: number) => void
): Promise<FFmpegSessionResult>
The onSessionCreated callback receives the ID before the command completes, allowing targeted cancellation while work is running.
probe(arguments, onLog?, onSessionCreated?)Starts an asynchronous FFprobe session.
function probe(
arguments_: string[],
onLog?: (message: string) => void,
onSessionCreated?: (sessionId: number) => void
): Promise<FFmpegSessionResult>
onSessionCreated can be used to correlate the native probe session with its eventual result. The bundled native dependency does not expose FFprobe cancellation.
getMediaInformation(path)Runs FFprobe for the format, streams, and chapters at a local media path, then parses its JSON response.
function getMediaInformation(path: string): Promise<MediaInformation>
MediaInformation types the format, streams, and chapters sections of FFprobe's report. Every field is optional because FFprobe only prints what the container exposes, and numeric values such as duration and bit_rate arrive as strings, exactly as FFprobe prints them. Unknown keys are preserved under an index signature.
const info = await getMediaInformation(inputPath)
const video = info.streams?.find((stream) => stream.codec_type === 'video')
console.log(video?.width, video?.height, info.format?.duration)
getMediaDuration(information)Reads the duration in seconds from a MediaInformation report, falling back to the first stream that reports one. Returns undefined when FFprobe did not report a duration (live inputs, some raw streams).
function getMediaDuration(information: MediaInformation): number | undefined
cancel(sessionId?)Cancels the given native FFmpeg execution session. FFmpeg runs one session at a time and the native core cannot single one out, so cancelling an active session also cancels any sessions queued behind it. An ID whose session has already finished, or that was never issued, is ignored. Calling cancel() without an ID cancels all active FFmpeg sessions. FFprobe cancellation is not exposed by the bundled native dependency.
function cancel(sessionId?: number): void
Session IDs must be positive safe integers received from onSessionCreated or FFmpegSessionResult.
cancelAll()Cancels every active FFmpeg session.
function cancelAll(): void
pause(sessionId) and resume(sessionId)Pauses a session started with execute(), whether it is running or still queued behind another one, and resumes it later.
function pause(sessionId: number): boolean
function resume(sessionId: number): boolean
FFmpeg itself has no pause, so munim-ffmpeg stops the threads that read its inputs. Everything downstream finishes the frames it already has and then waits, so a paused session uses no CPU. Output files stay open, and resume() carries on from the exact packet where reading stopped. Nothing is re-encoded, skipped or duplicated, and the execute() promise settles once, when the work is done. -re and -readrate inputs have their clocks shifted by the time spent paused, so they do not rush to catch up afterwards.
pause() returns false if the session is unknown, has already finished, or is an FFprobe session. resume() returns false if the session was not paused. cancel() and cancelAll() work while paused.
Notes:
timeMs. speed includes the paused time.import { execute, pause, resume } from 'munim-ffmpeg'
let sessionId: number | undefined
const done = execute(args, undefined, undefined, (id) => {
sessionId = id
})
// Later, from a pause button:
pause(sessionId!)
// …and from a resume button:
resume(sessionId!)
const result = await done
getSessionState(sessionId)type FFmpegSessionState =
| 'queued'
| 'running'
| 'paused'
| 'completed'
| 'failed'
| 'cancelled'
| 'unknown'
function getSessionState(sessionId: number): FFmpegSessionState
queued sessions are waiting for the one running ahead of them, since FFmpeg runs one execution at a time. unknown means the ID was never issued, or it finished long enough ago that its record was dropped (the last 512 finished sessions are kept).
getFFmpegVersion()Returns the version reported by the bundled native FFmpeg library.
function getFFmpegVersion(): string
listEncoders()Returns the encoder names the bundled FFmpeg build can write. The result is cached after the first call.
function listEncoders(): Promise<string[]>
listDecoders()Returns the decoder names the bundled FFmpeg build can read.
function listDecoders(): Promise<string[]>
listMuxers() / listDemuxers()Return the container formats the bundled FFmpeg build can write and read, e.g. mp4, matroska, webm. Cached after the first call.
function listMuxers(): Promise<string[]>
function listDemuxers(): Promise<string[]>
listFilters()Returns the filter names the bundled FFmpeg build provides, e.g. subtitles, ass, drawtext, scale.
function listFilters(): Promise<string[]>
listProtocols()Returns the protocol names the bundled FFmpeg build provides, e.g. file, https, concat.
function listProtocols(): Promise<string[]>
pickEncoder(candidates)Returns the first name in candidates that the build provides, or undefined when none are available. Use it to write one command that runs on both platforms.
function pickEncoder(candidates: string[]): Promise<string | undefined>
const hevc = await pickEncoder(['libx265', 'hevc_videotoolbox'])
normalizePath(value)Converts a file:// URI into the plain path FFmpeg expects, and returns anything else untouched.
function normalizePath(value: string): string
execute(), probe(), and getMediaInformation() already apply this to every argument, so you rarely need to call it directly. It is exported for cases where you build a path yourself — a concat list file, for example, whose entries FFmpeg reads verbatim.
FFmpegSessionResulttype FFmpegSessionResult = {
sessionId: number
returnCode: number
success: boolean
cancelled: boolean
state: string
durationMs: number
output: string
failStackTrace?: string
}
state is always completed once the promise settles, on both platforms; use success, cancelled, and returnCode to tell how the session ended, or getSessionState() for completed, failed, or cancelled.
Always check success or cancelled; Promise resolution means the native session completed, not necessarily that FFmpeg returned a success code.
import { getMediaInformation } from 'munim-ffmpeg'
const information = await getMediaInformation(inputPath)
console.log(JSON.stringify(information, null, 2))
import { execute } from 'munim-ffmpeg'
const result = await execute([
'-y',
'-i',
inputPath,
'-vn',
'-c:a',
'aac',
outputAudioPath,
])
if (!result.success) {
throw new Error(result.failStackTrace ?? result.output)
}
import { execute, pickEncoder } from 'munim-ffmpeg'
const encoder = await pickEncoder([
'h264_videotoolbox',
'h264_mediacodec',
'libopenh264',
])
if (!encoder) throw new Error('No H.264 encoder available in this build')
// MediaCodec wants NV12 input; the others take planar YUV.
const pixelFormat = encoder === 'h264_mediacodec' ? 'nv12' : 'yuv420p'
const quality = ['-b:v', '2M', '-pix_fmt', pixelFormat]
const result = await execute([
'-y',
'-i',
inputPath,
'-c:v',
encoder,
...quality,
'-c:a',
'aac',
'-pix_fmt',
'yuv420p',
outputPath,
])
if (!result.success) throw new Error(result.failStackTrace ?? result.output)
import { execute } from 'munim-ffmpeg'
const result = await execute([
'-y',
'-ss',
'00:00:01.000',
'-i',
inputPath,
'-frames:v',
'1',
outputImagePath,
])
AVIF is AV1 in an image container. libaom-av1 encodes it; -still-picture 1 switches the encoder into single-image mode and -f avif picks the container. Decoding an AVIF back — or any AV1 video — goes through dav1d automatically.
await execute([
'-y',
'-ss',
'1.5',
'-i',
inputPath,
'-frames:v',
'1',
'-vf',
'scale=-2:720',
'-c:v',
'libaom-av1',
'-still-picture',
'1',
'-cpu-used',
'6', // 0 (slowest, best) … 8 (fastest)
'-crf',
'28', // quality; lower is larger
'-pix_fmt',
'yuv420p',
'-f',
'avif',
outputPath, // ends in .avif
])
React Native's <Image> displays AVIF natively on iOS 16+ and Android 12+.
The bundled builds include libass with FreeType, HarfBuzz, and FriBidi, so ASS/SSA styling and complex scripts (Arabic, Urdu, and other RTL or shaped text) render correctly. System fonts are found automatically — through Core Text on iOS and through fontconfig scanning /system/fonts on Android.
import { execute, normalizePath } from 'munim-ffmpeg'
// ASS/SSA keeps its embedded styling: fonts, colours, outlines,
// shadows, positioning, karaoke — everything the format supports.
await execute([
'-y',
'-i',
inputPath,
'-vf',
`ass=filename=${normalizePath(subtitlePath)}`,
'-c:a',
'copy',
outputPath,
])
// SRT can be styled at burn time with force_style.
await execute([
'-y',
'-i',
inputPath,
'-vf',
`subtitles=filename=${normalizePath(srtPath)}:force_style='Fontsize=28,PrimaryColour=&H00FFFF00,Outline=2'`,
'-c:a',
'copy',
outputPath,
])
To ship your own fonts instead of relying on the device's, put them in a directory and add :fontsdir=/path/to/fonts to the filter. Note that filter arguments are colon-separated, so a path containing : must be escaped — app sandbox paths on both platforms are safe as-is.
Matroska muxing and demuxing is compiled in, along with FFmpeg's standard -map stream selection and -c copy remuxing:
import { execute } from 'munim-ffmpeg'
// Bundle one video, two audio languages, and a subtitle track into MKV.
await execute([
'-y',
'-i',
videoPath,
'-i',
urduAudioPath,
'-i',
subtitlePath,
'-map',
'0:v:0',
'-map',
'0:a:0',
'-map',
'1:a:0',
'-map',
'2:s:0',
'-c:v',
'copy',
'-c:a',
'aac',
'-c:s',
'srt',
'-metadata:s:a:1',
'language=urd',
outputMkvPath,
])
// Extract the second audio track without re-encoding.
await execute([
'-y',
'-i',
outputMkvPath,
'-map',
'0:a:1',
'-c',
'copy',
trackPath,
])
The other subtitle workflow: instead of burning text into the frames, mux the subtitle file in as its own stream, so players can toggle it and the video is never re-encoded. Each container wants its own subtitle codec — MKV takes srt and ass (ASS keeps its styling), MP4 takes mov_text, WebM takes webvtt.
import { execute } from 'munim-ffmpeg'
// MKV with English SRT and styled Urdu ASS tracks, video and audio untouched.
await execute([
'-y',
'-i',
videoPath,
'-i',
englishSrtPath,
'-i',
urduAssPath,
'-map',
'0:v:0',
'-map',
'0:a:0',
'-map',
'1:0',
'-map',
'2:0',
'-c:v',
'copy',
'-c:a',
'copy',
'-c:s:0',
'srt',
'-c:s:1',
'ass',
'-metadata:s:s:0',
'language=eng',
'-metadata:s:s:0',
'title=English',
'-metadata:s:s:1',
'language=urd',
'-metadata:s:s:1',
'title=Urdu',
outputMkvPath,
])
// MP4 needs mov_text instead.
await execute([
'-y',
'-i',
videoPath,
'-i',
subtitlePath,
'-map',
'0:v:0',
'-map',
'0:a:0',
'-map',
'1:0',
'-c:v',
'copy',
'-c:a',
'copy',
'-c:s',
'mov_text',
'-metadata:s:s:0',
'language=eng',
outputMp4Path,
])
Existing subtitle streams survive remuxing with -map 0 -c copy, and a soft track can later be burned in with -vf subtitles=filename=input.mkv:si=0 (the si option picks the subtitle stream index).
import { cancel, execute } from 'munim-ffmpeg'
let sessionId: number | undefined
const execution = execute(
['-i', inputPath, '-c:v', 'mpeg4', outputPath],
undefined,
undefined,
(createdSessionId) => {
sessionId = createdSessionId
}
)
const cancelCurrentCommand = () => {
if (sessionId !== undefined) {
cancel(sessionId)
}
}
const result = await execution
console.log(result.cancelled)
import { probe } from 'munim-ffmpeg'
const result = await probe([
'-v',
'error',
'-select_streams',
'v:0',
'-show_entries',
'stream=codec_name,width,height,duration',
'-of',
'json',
inputPath,
])
if (result.success) {
console.log(result.output)
}
The JavaScript, TypeScript, Swift, Kotlin, C core, and generated Nitro bridge in this repository are Apache-2.0.
The bundled FFmpeg 9.0.2 is LGPLv3, on both platforms. It is configured without --enable-gpl, so no x264, x265, xvid, or vid.stab. The external libraries it links are LAME (LGPL), Opus (BSD), libvpx (BSD), dav1d (BSD), libaom (BSD 2-clause with the Alliance for Open Media patent licence), openh264 (BSD 2-clause), libass (ISC), FreeType (FTL, BSD-style with credit), HarfBuzz (MIT-style), FriBidi (LGPL), and, on Android only, mbedTLS (Apache-2.0), fontconfig (MIT-style), and expat (MIT). None of them change the LGPL story.
A note on H.264 patents. Hardware encoders are covered by the licences device manufacturers already pay for. Software H.264 encoding through
libopenh264is not: Cisco's royalty coverage applies to their prebuilt binary, and this package builds openh264 from source. If you ship software H.264 encoding at scale, check where you stand with AVC licensing. Hardware encoders avoid the question entirely, which is whypickEncodershould list them first.
In practice that means your application does not inherit GPL obligations. LGPL still applies: the FFmpeg libraries are linked and their license and notices must be conveyed with your app, and users must be able to relink against a modified FFmpeg. The exact configuration used is recorded in scripts/ffmpeg/build-ios.sh and build-android.sh, and the binaries can be reproduced from them.
If you need x264 or x265, add --enable-gpl --enable-libx264 --enable-libx265 to those scripts and rebuild — but then your application does inherit GPLv3.
See FFmpeg legal guidance. This section is an engineering summary, not legal advice.
munim-ffmpeg is unavailable in Expo GoThis is expected. Install the package and create an Expo development build with npx expo run:ios, npx expo run:android, or EAS Build.
MunimFfmpeg cannot be foundRebuild the native app after installing both munim-ffmpeg and react-native-nitro-modules. Restarting Metro alone cannot add a native module to an existing binary.
content:// inputs into app storage.Native FFmpeg variants do not bundle every codec, filter, or third-party library, and the iOS and Android builds are not identical. Call listEncoders(), listDecoders(), listMuxers(), listDemuxers(), listFilters(), or listProtocols() to see what the running build actually has, and prefer pickEncoder() over a hard-coded name. There is no libx264 in these LGPL builds — H.264 comes from the platform's hardware encoder or libopenh264 — see Bundled FFmpeg builds.
Inspect result.success, result.cancelled, result.returnCode, result.output, and result.failStackTrace. A resolved Promise represents a completed native session; FFmpeg can still finish with a nonzero return code.
Run pod install after installation and rebuild from a clean native development build. If the linker cannot find MunimFFmpeg.xcframework, the native binaries were not downloaded — run npx munim-ffmpeg-fetch-binaries.
Use Android API 24 or newer, JDK 17, and the React Native New Architecture. Clear stale Gradle build output after changing native dependency versions.
If the build fails on duplicate libc++_shared.so, make sure the config plugin ran (Expo) or add android.packagingOptions.pickFirsts=**/libc++_shared.so to gradle.properties (bare React Native).
If System.loadLibrary cannot find munimffmpeg9, the native binaries were not downloaded — run npx munim-ffmpeg-fetch-binaries.
npm install
npm run check # codegen, typecheck, example typecheck, build, pack dry-run
Individual steps are available as npm run codegen, typecheck, typecheck:example, and build.
Nitrogen output under nitrogen/generated is committed. Change the .nitro.ts specification and rerun npm run codegen instead of editing generated files directly.
example/ is an Expo SDK 57 app — a development build, since Expo Go cannot load native modules — with two screens:
expo-document-picker (or generates one from JavaScript fixtures), inspects it, transcodes it through the device's hardware H.264 encoder via pickEncoder, writes an AVIF still, embeds soft subtitles into an MKV, burns them in with libass, and shows progress from the statistics callback with a cancel button wired to onSessionCreated. Each action is a plain argument array, so example/Playground.tsx doubles as a recipe book.munim-ffmpeg-suite.json to the app's document directory, and logs it as MUNIM_FFMPEG_SUITE_RESULT.npm run example:ios
# or
npm run example:android
FFmpeg encoding is slow in a simulator or emulator, and emulators have no working hardware encoder; run it on a physical device. example/README.md has the details.
npm run binaries:build # every architecture, ~40 minutes
npm run binaries:package # bundles them and records the checksum
See scripts/ffmpeg/README.md for what the build does and the platform quirks it works around.
Releases run locally from a clean main:
npm run check
npm run release:local
release:local runs semantic-release with the npm token from the macOS Keychain and the GitHub CLI token, so commit messages must follow Conventional Commits. It also uploads dist-binaries/munim-ffmpeg-binaries.tar.gz and build-info.txt to the GitHub release, which is where postinstall fetches the binaries from — so run npm run binaries:package first.
Two GitHub Actions workflows back this up: CI checks the JavaScript surface on every push and pull request, and Build binaries compiles every iOS and Android slice in parallel, on demand or when a pull request touches scripts/ffmpeg/, and can attach the result to a release. See scripts/ffmpeg/README.md.
Issues and pull requests are welcome. Include the following when reporting a problem:
react-native-nitro-modules versionFFmpegSessionResult and relevant native logsPlease keep licensing implications explicit when proposing a new FFmpeg binary variant, codec, or linked library.
The munim-ffmpeg source is available under the Apache License 2.0. Native FFmpeg dependencies are licensed separately as described above.