Skip to content

feat: migrate react-native-compressor to Nitro Modules#401

Merged
numandev1 merged 17 commits into
numandev1:mainfrom
HuuNguyen312:feature/nitro-module
Jun 12, 2026
Merged

feat: migrate react-native-compressor to Nitro Modules#401
numandev1 merged 17 commits into
numandev1:mainfrom
HuuNguyen312:feature/nitro-module

Conversation

@HuuNguyen312

Copy link
Copy Markdown
Contributor

Summary

The native Compressor module was bridged through React Native TurboModules + RCTEventEmitter, which forced the spec to be duplicated across four places (src/Spec/NativeCompressor.ts, android/src/{old,new}arch/CompressorSpec.kt, CompressorModule.kt, ios/Compressor.mm + CompressorManager.swift) and delivered progress over a global, uuid-filtered event emitter.

This PR migrates it to a single Nitro HybridObject (react-native-nitro-modules, generated by Nitrogen), so there is one spec source of truth (src/specs/Compressor.nitro.ts), a statically-compiled JSI binding (faster, no bridge), and progress delivered through first-class callbacks instead of events. All heavy native logic (MediaCodec/MediaMuxer transcoder, NextLevelSessionExporter, uploader/downloader/thumbnail) is preserved, and the public JS API is unchanged.

Changelog

[General] [Breaking] - Migrate the Compressor native module from TurboModules to a Nitro HybridObject; now requires the New Architecture and react-native-nitro-modules (>=0.35.0) as a peer dependency

[General] [Changed] - Deliver compression/upload/download progress via callback parameters instead of NativeEventEmitter events (public JS API unchanged)

[General] [Removed] - Remove the four-place TurboModule spec duplication in favor of a single src/specs/Compressor.nitro.ts (+ committed nitrogen/generated/)

[iOS] [Changed] - Replace Compressor.mm/CompressorManager.swift with HybridCompressor.swift; require iOS 13.4 / Xcode 16.4

[Android] [Changed] - Replace CompressorModule/CompressorPackage with HybridCompressor + Nitrogen autolinking; require compileSdk 34 / minSdk 24

Test Plan

  • Ran local JS PR gate: yarn test:pr

    Test Suites: 1 passed, 1 total
    Tests:       13 passed, 13 total
    tsc --noEmit  -> 0 errors
    eslint        -> 0 errors
    
  • Changes native compression/upload/download behavior — ran the Harness tests on a simulator/device:

    yarn test:harness:android   # pass (emulator)
    yarn test:harness:ios       # pass (simulator)

Consumer migration: yarn add react-native-nitro-modules then cd ios && pod install. No JS changes required.

HuuNguyen312 and others added 10 commits May 14, 2026 10:04
  iPhone HDR .MOV uses video/dolby-vision mime which has no
  standalone Android decoder, so createDecoderByType throws
  "Failed to initialize video/dolby-vision". DV profiles 8.x
  carry an HEVC base layer, so remap mime to video/hevc before
  configuring the decoder. Reject profile 5 (0x20) explicitly
  since it has no HEVC fallback.

  Perf, bundled to land with the codec rework:
  - Pick HW AVC encoder via MediaCodecList(ALL_CODECS), blacklist
    c2.qti.avc.encoder (corrupt MP4 on Mac/iOS).
  - Feed decoder until input slots drain instead of one sample
    per loop; unblocks parallel decode-render-encode.
  - Drop decoded frames whose PTS precedes the next target slot
    when source fps exceeds output fps.
  - Encoder: VBR + KEY_PRIORITY=0 + KEY_OPERATING_RATE=MAX to
    unthrottle HW codec scheduling.
  - Route SurfaceTexture onFrameAvailable to a dedicated
    HandlerThread so awaitNewImage stops contending with the
    main/JS thread.
  - Skip StreamableVideo rewrite unless caller passed a
    streamableFile; halves disk I/O for chat uploads.
Android: extract METADATA_KEY_LOCATION and write an Apple-style "©xyz"
udta atom into the muxed MP4 so geotags survive transcoding.
iOS: forward asset.metadata plus every available metadata format to the
AVAssetExportSession so location, creation date, and other tags are
retained in the exported file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- fps: derive from frame_count/duration when CAPTURE_FRAMERATE absent.
  Cap 30→60. Drop-gate only when source>target, anchor to ideal grid.
- bitrate: WhatsApp envelope (~1.5 Mbps @ 720p). Android+iOS sync.
- GPS: LocationExtractor walks MP4 — ©xyz, loci, iTunes meta/keys+ilst,
  SEF trailer regex. Writer ©xyz moved to LocationBox class.
- teardown: runCatching every dispose step. join() OutputSurface thread
  after quitSafely to avoid SIGABRT on stale pthread_t.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…encoder/teardown

- LocationExtractor/Compressor: log only location presence + source, never the
  ISO 6709 coordinate string (xyz/itunes/loci/SEF/resolved values)
- Compressor: preflight Dolby Vision profile 5 before allocating
  muxer/encoder/EGL surfaces and drop the throw from prepareDecoder, so the
  unsupported case no longer leaks codec/GL resources on bail-out
- Compressor: restore always-on streamable rewrite (moov atom to front) for
  default output to preserve progressive playback (revert behavior change)
- CompressorUtils/Compressor: make VBR/priority/operating-rate throughput
  tuning optional and fall back to a default-rate-control configure when an
  encoder rejects the tuned format
- Compressor: release partially-initialized encoder/decoder/EGL surfaces on any
  setup failure or in-loop throw (dispose tolerates null handles)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MP4Builder opened its FileOutputStream/FileChannel in createMovie() but
only closed them in finishMovie(). Any failure between muxer creation and
a successful finishMovie() leaked the output file handle.

- Add idempotent MP4Builder.close() to release streams without finalizing
- createMovie() now closes its own streams if header writing throws
- Compressor closes the muxer in the setup/in-loop catch, the finishMovie
  catch, and the outer catch (processAudio/extractor.release failures)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the legacy TurboModule/RCTEventEmitter bridge with a single Nitro
HybridObject ("Compressor") generated by nitrogen, removing the four-place
spec duplication in favor of one `.nitro.ts` source of truth.

* JS: drop `src/Spec/NativeCompressor.ts`; add `src/specs/Compressor.nitro.ts`
* Android: remove `CompressorModule`/`CompressorPackage`; add `HybridCompressor`
* iOS: remove `Compressor.mm`/`.h`, `CompressorManager.swift`; add `HybridCompressor.swift`
* Build: add `nitro.json` + nitrogen-generated sources, wire nitrogen into `prepack`

BREAKING CHANGE: `react-native-compressor` now requires the New Architecture
and `react-native-nitro-modules` (>=0.35.0) as a peer dependency.
@HuuNguyen312 HuuNguyen312 changed the title feat: migrate Compressor native module to Nitro Modules feat: migrate react-native-compressor to Nitro Modules Jun 9, 2026
@numandev1

Copy link
Copy Markdown
Owner

@HuuNguyen312
Screenshot 2026-06-09 at 6 32 16 PM
can you also fix these two github action check?

@HuuNguyen312

Copy link
Copy Markdown
Contributor Author

@numandev1 I’m checking them now.
I have fully migrated everything to Nitro Modules using Claude Opus 4.8. Please leave a comment if you notice any potential risks.

HuuNguyen312 and others added 3 commits June 9, 2026 21:37
Under Nitro the pod compiles with Swift<->C++ interop. `import React`
dragged React's move-only C++ type jsinspector RuntimeSamplingProfile into
Swift's ClangImporter, which Xcode 16.4 mis-imports as copyable, failing
the build (`__construct_at` / SwiftCompile error, exit 65).

React was only used for the RCTPromiseResolveBlock/RejectBlock typedefs
(now synthesized and consumed entirely in Swift) and one vestigial
RCTResizeMode.contain. Define the two blocks as local Swift typealiases and
replace the enum with a local stand-in, so no Swift file imports React.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Validate Android job runs `./gradlew lint`, which lints every
autolinked dependency. react-native-video trips the WrongThread check
(ReactExoplayerView.getVideoTrackInfoFromManifest) -- not our code -- and
aborts the lint build (pre-existing failure, red on main too). Register a
settings-time hook that disables just that one check on android library
subprojects, before AGP locks the lint DSL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After disabling WrongThread on library subprojects, the example app's own
lint still failed on two manifest-merge errors from third-party deps:
react-native-video's VideoPlaybackService (NotificationPermission) and the
media/camera permissions (PermissionImpliesUnsupportedChromeOsHardware).
Disable just those two checks in the app's lint config. Full `./gradlew
lint` now passes locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@HuuNguyen312

Copy link
Copy Markdown
Contributor Author

@numandev1 All checks have passed

@numandev1

Copy link
Copy Markdown
Owner

@HuuNguyen312 can you resolve conflicts?

@HuuNguyen312

Copy link
Copy Markdown
Contributor Author

@numandev1 Conflicts resolved.

@numandev1 numandev1 merged commit 9a413b3 into numandev1:main Jun 12, 2026
4 checks passed
@numandev1

Copy link
Copy Markdown
Owner

relased in 2.0.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants