react-native-compressor is a React Native library that compresses Image, Video, and Audio (WhatsApp-style auto compression, plus manual mode), with background upload/download and video thumbnail helpers. The TypeScript/JS layer is a thin wrapper over a single native module named Compressor implemented natively on Android (Kotlin) and iOS (Swift). It is published to npm as a library — the examples/ apps exist only to develop and test against it.
JS layer: TypeScript · single native module Compressor exposed as a Nitro HybridObject (react-native-nitro-modules), resolved in src/Main.tsx via NitroModules.createHybridObject
Android native: Kotlin · hand-rolled MediaCodec/MediaMuxer video transcoder
iOS native: Swift (C++/Swift interop) · AVFoundation via vendored NextLevelSessionExporter.swift
Codegen: Nitrogen (yarn nitrogen) generates the native bindings from the *.nitro.ts spec into nitrogen/generated/ (committed to git)
Tooling: Yarn 4 (Berry) workspace (examples/*) · Node >= 22.11 · Jest (native mocked) · react-native-builder-bob · Expo config plugin
Requirements (Nitro): RN ≥ 0.75 · iOS ≥ 13.4 / Xcode ≥ 16.4 · Android compileSdk ≥ 34 · C++20. Works on both old & new architecture (Nitro handles its own linking)
All native functionality is exposed through one Nitro HybridObject named Compressor. src/Main.tsx resolves it once via NitroModules.createHybridObject<Compressor>('Compressor'), typed by the spec src/specs/Compressor.nitro.ts, and re-throws a friendly linking error if Nitro can't find it. Options are passed as Nitro AnyMap (untyped maps), parsed natively as before.
The public API is assembled in src/index.tsx from four domain modules plus utils:
src/Image/index.tsx—Image.compress(strips base64 data-URI headers before calling native)src/Video/index.tsx—Video.compress,cancelCompression,activate/deactivateBackgroundTasksrc/Audio/index.tsx—Audio.compresssrc/utils/—Uploader.tsx(backgroundUpload,cancelUpload),Downloader.tsx(download),helpers.ts, and metadata/path helpers (getRealPath,getVideoMetaData,getImageMetaData,generateFilePath,createVideoThumbnail,clearCache,getFileSize)
Nitro has no NativeEventEmitter. Progress is delivered through callback functions passed as method parameters (onProgress, onDownloadProgress, onExpired) — first-class, reference-counted, auto-scheduled onto the JS thread. Callbacks can't live inside an AnyMap, so any callback that used to be nested in the options object is lifted to a top-level method parameter (the JS layer strips functions/undefined from option maps via toNativeOptions in src/utils/helpers.ts, since Nitro's AnyMap throws on those).
A uuid (uuidv4) is still generated in JS and threaded inside the options map, but now only for (a) cancellation (cancelCompression, cancelUpload, AbortController) and (b) routing native progress emissions to the correct callback. Natively, the per-domain code still calls EventEmitterHandler.emit*, but that class is now a uuid → callback registry (not a bridge emitter): the binding registers the JS callback under the uuid before invoking the domain method and unregisters when the Promise settles. Keep the uuid threading consistent across JS and both native sides.
The thin Nitro binding lives separately from the heavy domain logic:
- Android binding
android/src/main/java/com/margelo/nitro/compressor/HybridCompressor.kt(extends the generatedHybridCompressorSpec) convertsAnyMap→ReadableMap, bridges the NitroPromiseto the domain layer'scom.facebook.react.bridge.PromiseviaNitroPromiseAdapter.kt, and runs domain work on a background executor.NitroCompressorPackage.kt(aBaseReactPackage) exists only so RN autolinking registers the Gradle project and itscompanion initloadslibNitroCompressor.so. - iOS binding
ios/HybridCompressor.swift(implementsHybridCompressorSpec) convertsAnyMap→NSDictionary, synthesizesRCTPromiseResolveBlock/RejectBlockto drive the NitroPromise.
Heavy domain logic (unchanged, mirrors the JS domains):
- Android
android/src/main/java/com/reactnativecompressor/→Image/,Video/,Audio/,Utils/. The video transcoder is hand-rolled underVideo/VideoCompressor/(MediaCodec/MediaMuxer pipeline:Compressor.kt,MP4Builder.kt, surfaces/renderer,utils/).VideoMain.compressroutes to auto vs manual viaVideoCompressorHelper.StreamableVideo.ktmoves themoovatom to the front of the output by default — preserve this behavior. - iOS
ios/→Image/,Audio/(withFormatConverter/),Video/,Utils/. Video usesNextLevelSessionExporter.swift(a vendored AVFoundation exporter) driven byVideoMain.swift. Domain Swift filesimport ReactforRCTPromise*(bridging headers are unsupported under RN 0.85 framework linkage), andFormatConverter/AudioFileFormatareinternalto keep them out of the Swift↔C++ interop surface. - On both platforms
EventEmitterHandleris the uuid→callback registry described above.
src/expo-plugin/compressor.ts is an Expo config plugin; app.plugin.js loads its built output from lib/commonjs/expo-plugin/compressor (so the plugin only works after yarn prepack).
Uses Yarn 4 (Berry) and a Yarn workspace (examples/*). Node >= 22.11.
yarn # install deps for root + example workspaces
yarn test # Jest unit tests (native is mocked — fast, no device)
yarn test path/to/file # run a single test file
yarn test -t "pattern" # run tests matching a name pattern
yarn typecheck # tsc --noEmit
yarn lint # eslint over **/*.{js,ts,tsx}
yarn lint --fix # auto-fix lint/prettier
yarn test:pr # full PR gate: test --runInBand + typecheck + lint
yarn nitrogen # regenerate Nitro native bindings into nitrogen/generated/ (run after editing the *.nitro.ts spec)
yarn prepack # nitrogen + build the publishable lib/ via react-native-builder-bob
yarn build:android # assemble the bare example (arm64-v8a) — native Android compile check
yarn build:ios # build the bare example for the iOS simulator — native iOS compile check
yarn clean # delete android/ios build dirsExample apps (workspace shortcuts):
yarn example:bare start # Metro for the bare RN example
yarn example:bare android # run bare example on Android
yarn example:bare ios # run bare example on iOS
yarn example:expo start # Expo exampleThe Jest unit tests mock the native module, so they validate only the JS contract — real media decoding/encoding is only exercised by the harness tests, which run inside the bare example app on a booted simulator/emulator:
yarn test:harness:android # requires an Android emulator (default Pixel_8_API_35)
yarn test:harness:ios # requires an iOS simulator (default iPhone 17 Pro, iOS 26.4)Device/version overrides live in examples/bare/rn-harness.config.mjs (env vars RN_HARNESS_ANDROID_DEVICE, RN_HARNESS_IOS_DEVICE, RN_HARNESS_IOS_VERSION, etc.). The harness spec is harness/native-compressor.harness.ts (the copy under examples/bare/harness/ re-exports it).
When you change native Swift/Kotlin code and can't run it on a device/simulator, state clearly that it was not runtime-verified.
VideoMain.swiftbuilds thevideoOutputConfiguration/compressionDictand drivesNextLevelSessionExporter.NextLevelSessionExporter.setupVideoOutputonly creates the video writer input whenwriter.canApply(outputSettings:forMediaType:) == true; otherwise it logs"Unsupported output configuration"and writes audio only, yet still ends as.completed. That means a badvideoOutputConfigurationcan silently yield an audio-only MP4 reported as success.- Do not add undocumented H.264 (
avc1) compression properties such asAVVideoExpectedSourceFrameRateKeyorAVVideoAverageNonDroppableFrameRateKey:canApply(...)accepts them but the iOS encoder drops the video track (regression in #392, fixed for #400). After export, verify the output asset actually contains a video track before resolving success.
Compressor.ktruns anMediaExtractor→ decoder (Surface) → encoder (video/avc) →MP4Buildertranscode loop.- The decoder is created from the input track's MIME. Some containers (notably
iPhone
.MOV) reportvideo/dolby-vision, which fails withNAME_NOT_FOUNDon devices lacking a Dolby Vision decoder.CompressorUtils.ensureDecodableVideoFormatremaps such inputs to their backward-compatible HEVC/AVC base layer (profiles 8/4 → HEVC, profile 9 → AVC) or throws a clear error for non-compatible profiles (5/7). See #398. - The encoder is intentionally
c2.android.avc.encoder(when QTI codecs exist) orMediaCodec.createEncoderByType("video/avc"); QTI AVC encoders can produce MP4s that do not play on Mac/iPhone, so avoid switching this without testing.
- One Nitro spec — regenerate after editing. The spec lives in ONE place:
src/specs/Compressor.nitro.ts(config innitro.json). After adding/renaming/changing a method, runyarn nitrogenand commit the updatednitrogen/generated/, then update the two implementations:ios/HybridCompressor.swiftandandroid/.../com/margelo/nitro/compressor/HybridCompressor.kt. (No more old/new-arch specs,Compressor.mm, orRCTEventEmitter— those were deleted.) - Nitrogen Swift keyword gotcha: don't name a spec parameter after a Swift keyword (e.g.
extension) — nitrogen emits it unescaped and the generated Swift won't compile.generateFilePathusesfileExtensionfor this reason. - iOS framework linkage: RN 0.85 builds pods as frameworks, where bridging headers are unsupported. Swift files needing React types must
import React(andimport UIKitfor UIKit types). Public Swift value types with nested types (e.g.FormatConverter) must stayinternalor they break the Swift↔C++ interop link. - Android autolink/.so:
NitroCompressorPackagemust exist (RN CLI keys autolinking off aReactPackage, and itsinitloadslibNitroCompressor.so). After changing it, clearexamples/bare/android/build/generated/autolinkingif the project stops being found. - AnyMap is strict: option maps must contain only JSON-like values (no functions, no
undefined) — usetoNativeOptions. Numbers arrive natively asDouble; the binding round-trips throughReadableMap/NSNumberso the domain parsers'getInt/as? Intkeep working. - Streamable:
StreamableVideo.ktmovesmoovatom to front by default — preserve. - uuid threading: keep
uuidconsistent across JS + both native sides for cancellation + progress-callback routing. - Unsupported media: prefer graceful, descriptive failures over cryptic native crashes — clear error messages that tell the user what happened.
- Commits follow Conventional Commits (
fix:,feat:,refactor:,docs:,test:,chore:).commit-msghook runs commitlint;pre-commithook (lefthook) runs eslint +tsc --noEmiton staged files. Don't bypass. - Build output:
lib/and example workspaces excluded from lint/tsc/jest — don't editlib/by hand. - Releases: cut with
yarn release(release-it + conventional-changelog).
TRIAGE.md tracks the running triage of GitHub issues and the fixes made for them — when fixing an issue, record it there (triage row + the "Minor fixes made in this branch" list) referencing the issue number.
@.claude/rules/karpathy-guidelines.md