munim-ffmpeg 0.2.0 β†’ 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,25 +18,30 @@ Pod::Spec.new do |s|
18
18
  "ios/**/*.{swift}",
19
19
  # Autolinking/Registration (Objective-C++)
20
20
  "ios/**/*.{m,mm}",
21
+ # C core that drives FFmpeg's in-process command-line tools
22
+ "ios/*.h",
21
23
  # Implementation (C++ objects)
22
24
  "cpp/**/*.{hpp,cpp}",
23
25
  ]
24
26
 
27
+ # FFmpeg 9 and the in-process fftools core. Downloaded by
28
+ # scripts/fetch-binaries.mjs on install, or built by scripts/ffmpeg/build-all.sh.
29
+ s.vendored_frameworks = "ios/MunimFFmpeg.xcframework"
30
+ s.pod_target_xcconfig = {
31
+ "HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/ios\"",
32
+ }
33
+ s.frameworks = "AudioToolbox", "VideoToolbox", "CoreMedia", "AVFoundation", "CoreVideo", "Security"
34
+ s.libraries = "bz2", "z", "iconv", "c++"
35
+
36
+ # Must be public so CocoaPods puts it in the module umbrella, which is how the
37
+ # Swift implementation sees the C core.
38
+ s.public_header_files = "ios/munim_ffmpeg_core.h"
39
+
25
40
  load 'nitrogen/generated/ios/NitroMunimFfmpeg+autolinking.rb'
26
41
  add_nitrogen_files(s)
27
42
 
28
43
  s.dependency 'React-jsi'
29
44
  s.dependency 'React-callinvoker'
30
- s.dependency 'ffmpeg-kit-ios-full-gpl-alt', '6.0'
31
-
32
- # ffmpeg-kit 6.0 declares negative Level values with an unsigned backing type.
33
- # Xcode 26 rejects that header when Nitro enables Swift C++ interoperability.
34
- # The script locates Level.h by globbing, so it survives a change of FFmpegKit pod.
35
- s.script_phase = {
36
- :name => 'Patch FFmpegKit Level enum for Xcode 26',
37
- :script => '"${RUBY_EXECUTABLE:-/usr/bin/ruby}" "${PODS_TARGET_SRCROOT}/scripts/patch-ffmpegkit-level.rb"',
38
- :execution_position => :before_compile,
39
- }
40
45
 
41
46
  install_modules_dependencies(s)
42
47
  end
package/README.md CHANGED
@@ -61,7 +61,7 @@
61
61
 
62
62
  **Designed for Expo development builds and bare React Native.** This package contains native code and cannot run in Expo Go.
63
63
 
64
- > **Licensing note:** The JavaScript and Nitro bridge are Apache-2.0. The bundled native FFmpeg builds are not: the Android artifact is **GPLv3** (it links x264, x265, and xvid) and the iOS artifact is **LGPLv3**. Distributing either carries obligations. Read [Bundled FFmpeg builds](#bundled-ffmpeg-builds) before shipping.
64
+ > **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](#licensing).
65
65
 
66
66
  ## Table of contents
67
67
 
@@ -92,7 +92,8 @@
92
92
 
93
93
  ### FFmpeg execution
94
94
 
95
- - 🎬 **Argument-array commands:** Avoid platform-specific shell parsing and quoting
95
+ - 🎬 **Argument-array commands:** FFmpeg's own CLI code paths, without shell parsing or quoting
96
+ - πŸ†• **FFmpeg 9.0.1:** The current upstream release, identical on both platforms
96
97
  - ⚑ **Asynchronous sessions:** Keep the React Native thread responsive during native work
97
98
  - πŸ“ **Live logs:** Receive FFmpeg output as it is produced
98
99
  - πŸ“ˆ **Encoding statistics:** Track time, size, bitrate, speed, frames, FPS, and quality
@@ -112,7 +113,7 @@
112
113
  - πŸš€ **Expo compatible:** Autolinking, config plugin, and an Expo development example
113
114
  - πŸ§ͺ **Capability discovery:** Ask the bundled build which encoders and decoders it actually has
114
115
  - 🎯 **TypeScript:** Complete public callback and result types
115
- - πŸ—‚οΈ **16 KB Android support:** Uses a maintained FFmpegKit-compatible Android artifact with 16 KB page-size support
116
+ - πŸ—‚οΈ **16 KB Android pages:** Built with the alignment Google Play requires
116
117
 
117
118
  ## Platform support matrix
118
119
 
@@ -135,53 +136,46 @@ Codec availability is determined by the native FFmpeg builds described in [Bundl
135
136
 
136
137
  ## Bundled FFmpeg builds
137
138
 
138
- FFmpegKit was retired upstream in 2025 and its official binaries were withdrawn, so this package depends on maintained community rebuilds. The two platforms are **not** the same build:
139
+ Both platforms run **FFmpeg 9.0.1**, built from [ffmpeg.org](https://www.ffmpeg.org/) by the scripts in [`scripts/ffmpeg/`](./scripts/ffmpeg). There is no FFmpegKit here: that project was retired in 2025 and pinned to FFmpeg 6.0.
139
140
 
140
141
  | | iOS | Android |
141
142
  | --- | --- | --- |
142
- | Artifact | `ffmpeg-kit-ios-full-gpl-alt` 6.0 | `io.github.jamaismagic.ffmpeg:ffmpeg-kit-main-16kb` 6.1.7 |
143
- | FFmpeg | n6.0 | n6.1.4 |
144
- | Effective license | LGPLv3 | **GPLv3** |
143
+ | FFmpeg | 9.0.1 | 9.0.1 |
144
+ | Architectures | arm64 device, arm64 + x86_64 simulator | arm64-v8a, armeabi-v7a, x86_64 |
145
145
  | Hardware codecs | VideoToolbox, AudioToolbox | MediaCodec |
146
- | 16 KB page size | n/a | βœ… (required by Google Play) |
146
+ | TLS | SecureTransport | mbedTLS |
147
+ | Minimum | iOS 15.1 | API 24, 16 KB pages |
147
148
 
148
- Despite its name, the iOS pod ships FFmpeg's non-GPL configuration: it has no `libx264`, `libx265`, or `libxvid`. No public GPL build of FFmpegKit for iOS exists since the upstream retirement.
149
+ Linked libraries, identical on both: **LAME** (MP3), **Opus**, **libvpx** (VP8/VP9), **dav1d** (AV1 decoding), plus everything FFmpeg builds natively.
150
+
151
+ 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.
149
152
 
150
153
  ### Encoders
151
154
 
152
- Verified by running the example's device suite on both platforms: iOS reports 201 encoders, Android 196, with 181 in common. Everything FFmpeg builds natively (`aac`, `alac`, `flac`, `mpeg4`, `mjpeg`, `png`, `gif`, `pcm_*`, …) is available on both, as are `libmp3lame`, `libopus`, `libvpx` (VP8), and `libvpx-vp9`.
155
+ Verified by running the example's device suite: iOS reports 186 encoders, Android 184. Everything FFmpeg builds natively (`aac`, `alac`, `flac`, `mpeg4`, `mjpeg`, `png`, `gif`, `pcm_*`, …) is on both, as are `libmp3lame`, `libopus`, `libvpx`, and `libvpx-vp9`.
153
156
 
154
- The differences that matter:
157
+ H.264 and HEVC come from the platform's hardware encoder, which is faster and smaller than bundling x264 β€” and keeps the package LGPL:
155
158
 
156
159
  | Encoder | iOS | Android |
157
160
  | --- | --- | --- |
158
- | `libx264` / `libx264rgb` (H.264, software) | ❌ | βœ… |
159
- | `libx265` (HEVC, software) | ❌ | βœ… |
160
161
  | `h264_videotoolbox`, `hevc_videotoolbox`, `prores_videotoolbox` | βœ… | ❌ |
161
- | `h264_mediacodec`, `hevc_mediacodec`, `vp8_mediacodec`, `vp9_mediacodec`, `av1_mediacodec` | ❌ | βœ… |
162
- | `libkvazaar` (HEVC, software) | βœ… | ❌ |
163
- | `libtheora`, `libvorbis`, `libwebp` | βœ… | ❌ |
164
- | `libspeex`, `libshine`, `libtwolame`, `libilbc`, `libopencore_amrnb`, `libvo_amrwbenc` | βœ… | ❌ |
165
- | `aac_at`, `alac_at`, `ilbc_at` (AudioToolbox) | βœ… | ❌ |
166
-
167
- iOS additionally links libass, so subtitle burn-in filters work there but not on Android.
162
+ | `h264_mediacodec`, `hevc_mediacodec`, `vp8_mediacodec`, `vp9_mediacodec` | ❌ | βœ… |
163
+ | `aac_at`, `alac_at` (AudioToolbox) | βœ… | ❌ |
168
164
 
169
- Both platforms can encode H.264 and HEVC β€” just not with the same encoder name. Resolve it at runtime instead of branching on `Platform.OS`:
165
+ Resolve the name at runtime instead of branching on `Platform.OS`:
170
166
 
171
167
  ```typescript
172
168
  import { execute, pickEncoder } from 'munim-ffmpeg'
173
169
 
174
- const h264 = await pickEncoder(['libx264', 'h264_videotoolbox'])
170
+ const h264 = await pickEncoder(['h264_videotoolbox', 'h264_mediacodec'])
175
171
  if (!h264) throw new Error('No H.264 encoder in this build')
176
172
 
177
173
  await execute(['-y', '-i', inputPath, '-c:v', h264, outputPath])
178
174
  ```
179
175
 
180
- Decoding is far more uniform: both builds decode H.264, HEVC, VP8/VP9, AV1 (`libdav1d`), MPEG-4, MP3, AAC, Vorbis, Opus, FLAC, and the usual container formats. Both link GnuTLS, so `https://` inputs work.
176
+ 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.
181
177
 
182
- The Android build is compiled with `--disable-indev=lavfi`, so `-f lavfi -i testsrc=...` and other virtual inputs are iOS-only. Feed real files or raw frames instead.
183
-
184
- > **Avoid the `-full` and `-full-gpl` Android artifacts.** Their `libavdevice.so` references hidapi symbols that nothing in the package provides, so FFmpegKit fails to initialise at runtime with `UnsatisfiedLinkError: cannot locate symbol "PLATFORM_hid_write"`.
178
+ Decoding is uniform: H.264, HEVC, VP8/VP9, AV1, MPEG-4, MP3, AAC, Vorbis, Opus, FLAC and the usual containers, on both platforms. Both link TLS, so `https://` inputs work.
185
179
 
186
180
  ## πŸ“¦ Installation
187
181
 
@@ -201,7 +195,7 @@ pod install
201
195
  cd ..
202
196
  ```
203
197
 
204
- FFmpegKit and React Native both provide `libc++_shared.so`. Resolve that duplicate in the Android application module:
198
+ FFmpeg and React Native both provide `libc++_shared.so`. Resolve that duplicate in the Android application module:
205
199
 
206
200
  ```groovy
207
201
  android {
@@ -229,7 +223,7 @@ The package includes an Expo config plugin. If your project manages its plugin l
229
223
  }
230
224
  ```
231
225
 
232
- The plugin also configures Android to select one shared C++ runtime when React Native and FFmpegKit contribute the same `libc++_shared.so` path.
226
+ The plugin also configures Android to select one shared C++ runtime when React Native and FFmpeg contribute the same `libc++_shared.so` path.
233
227
 
234
228
  Create a native development build after installation:
235
229
 
@@ -244,6 +238,25 @@ You can also create an [EAS development build](https://docs.expo.dev/develop/dev
244
238
 
245
239
  > **Important:** `munim-ffmpeg` cannot run in Expo Go because Expo Go does not include this package's native libraries.
246
240
 
241
+ ### Native binaries
242
+
243
+ The FFmpeg libraries are around 200 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`.
244
+
245
+ If your environment blocks install scripts (`npm install --ignore-scripts`), fetch them explicitly:
246
+
247
+ ```bash
248
+ npx munim-ffmpeg-fetch-binaries
249
+ ```
250
+
251
+ Behind a proxy or an air-gapped mirror, point the fetcher somewhere else:
252
+
253
+ ```bash
254
+ MUNIM_FFMPEG_BINARIES_URL=https://internal.example.com/munim-ffmpeg-binaries.tar.gz \
255
+ npx munim-ffmpeg-fetch-binaries
256
+ ```
257
+
258
+ Or build them yourself β€” see [`scripts/ffmpeg/README.md`](./scripts/ffmpeg/README.md).
259
+
247
260
  ### Requirements
248
261
 
249
262
  - React Native with the New Architecture enabled
@@ -580,25 +593,15 @@ if (result.success) {
580
593
 
581
594
  ## Licensing
582
595
 
583
- The JavaScript, TypeScript, Swift, Kotlin, and generated Nitro bridge code in this repository are Apache-2.0. The native FFmpeg binaries are not, and the two platforms differ:
584
-
585
- | Platform | Native dependency | Version | Effective license |
586
- | -------- | ----------------- | ------- | ----------------- |
587
- | iOS | `ffmpeg-kit-ios-full-gpl-alt` | 6.0 | LGPLv3 (`--enable-version3`, no GPL libraries linked) |
588
- | Android | `io.github.jamaismagic.ffmpeg:ffmpeg-kit-main-16kb` | 6.1.7 | **GPLv3** (`--enable-gpl` with x264 and x265) |
596
+ The JavaScript, TypeScript, Swift, Kotlin, C core, and generated Nitro bridge in this repository are Apache-2.0.
589
597
 
590
- The Android artifact's published Maven metadata claims LGPL-3.0. That metadata is wrong: the shipped `libavcodec.so` is configured with `--enable-gpl` and the AAR bundles the x264 and x265 license notices. Treat the Android build as GPLv3.
598
+ The bundled FFmpeg 9.0.1 is **LGPLv3**, on both platforms. It is configured without `--enable-gpl`, so no x264, x265, xvid, or vid.stab: H.264 and HEVC encoding come from VideoToolbox and MediaCodec instead. The external libraries it does link are LAME (LGPL), Opus (BSD), libvpx (BSD), dav1d (BSD), and mbedTLS (Apache-2.0) on Android.
591
599
 
592
- Distributing an Android application built against it can trigger GPLv3 source, license, and redistribution obligations for your application. If that does not suit your product, replace the Android dependency in `android/build.gradle` with a non-GPL FFmpegKit artifact; H.264 encoding then relies on `h264_mediacodec`.
600
+ 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`](./scripts/ffmpeg/build-ios.sh) and [`build-android.sh`](./scripts/ffmpeg/build-android.sh), and the binaries can be reproduced from them.
593
601
 
594
- Before distributing an application:
602
+ 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.
595
603
 
596
- 1. Review the license and notices shipped by each native dependency.
597
- 2. Identify the codecs and linked libraries your product actually uses.
598
- 3. Follow the applicable LGPL, GPL, attribution, relinking, and source-offer requirements.
599
- 4. Reassess licensing whenever you replace a native dependency.
600
-
601
- See [FFmpeg legal guidance](https://ffmpeg.org/legal.html). This section is an engineering reminder, not legal advice.
604
+ See [FFmpeg legal guidance](https://ffmpeg.org/legal.html). This section is an engineering summary, not legal advice.
602
605
 
603
606
  ## πŸ” Troubleshooting
604
607
 
@@ -628,31 +631,15 @@ Inspect `result.success`, `result.cancelled`, `result.returnCode`, `result.outpu
628
631
 
629
632
  ### iOS pod or build errors
630
633
 
631
- Run `pod install` after installation and rebuild from a clean native development build. The package includes a narrowly scoped Xcode 26 compatibility patch for the FFmpegKit `Level` enum, which Nitro's Swift/C++ bridge otherwise rejects. The patch locates the header by globbing, so it keeps working if you swap the FFmpegKit pod.
632
-
633
- ### `building for iOS Simulator, but linking ... built for iOS`
634
-
635
- The FFmpegKit pod tells consuming apps to exclude `arm64` from Simulator builds, which breaks Apple Silicon Macs even though its xcframework contains an `arm64` Simulator slice. The Expo config plugin removes that exclusion automatically. In a bare React Native app, add the same fix to your `Podfile`:
636
-
637
- ```ruby
638
- post_install do |installer|
639
- installer.pods_project.build_configurations.each do |config|
640
- config.build_settings.delete('EXCLUDED_ARCHS[sdk=iphonesimulator*]')
641
- end
642
- installer.aggregate_targets.each do |aggregate_target|
643
- aggregate_target.xcconfigs.each do |config_name, xcconfig|
644
- xcconfig.attributes.delete('EXCLUDED_ARCHS[sdk=iphonesimulator*]')
645
- xcconfig.save_as(Pathname.new(aggregate_target.xcconfig_path(config_name)))
646
- end
647
- end
648
- end
649
- ```
634
+ 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`.
650
635
 
651
636
  ### Android build errors
652
637
 
653
638
  Use Android API 24 or newer, JDK 17, and the React Native New Architecture. Clear stale Gradle build output after changing native dependency versions.
654
639
 
655
- 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). FFmpegKit and React Native both ship that library.
640
+ 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).
641
+
642
+ If `System.loadLibrary` cannot find `munimffmpeg9`, the native binaries were not downloaded β€” run `npx munim-ffmpeg-fetch-binaries`.
656
643
 
657
644
  ## Development
658
645
 
@@ -677,6 +664,15 @@ npm run example:android
677
664
 
678
665
  FFmpeg encoding is slow in a simulator or emulator; run the suite on a physical device.
679
666
 
667
+ ### Rebuilding FFmpeg
668
+
669
+ ```bash
670
+ npm run binaries:build # every architecture, ~40 minutes
671
+ npm run binaries:package # bundles them and records the checksum
672
+ ```
673
+
674
+ See [`scripts/ffmpeg/README.md`](./scripts/ffmpeg/README.md) for what the build does and the platform quirks it works around.
675
+
680
676
  ### Releasing
681
677
 
682
678
  Releases run locally from a clean `main`; this repository does not use GitHub Actions.
@@ -686,7 +682,7 @@ npm run check
686
682
  npm run release:local
687
683
  ```
688
684
 
689
- `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.
685
+ `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` to the GitHub release, which is where `postinstall` fetches it from β€” so run `npm run binaries:package` first.
690
686
 
691
687
  ## πŸ‘ Contributing
692
688
 
@@ -139,9 +139,6 @@ dependencies {
139
139
  // Add a dependency on NitroModules
140
140
  implementation project(":react-native-nitro-modules")
141
141
 
142
- // Maintained FFmpegKit-compatible Android artifact with 16 KB page support.
143
- // Do not move to the `-full`/`-full-gpl` variants: their libavdevice.so
144
- // references hidapi symbols that nothing provides, so FFmpegKit fails to load
145
- // at runtime with UnsatisfiedLinkError.
146
- implementation "io.github.jamaismagic.ffmpeg:ffmpeg-kit-main-16kb:6.1.7"
142
+ // FFmpeg 9 and the in-process fftools core ship as prebuilt .so files in
143
+ // src/main/jniLibs; see scripts/ffmpeg/ for the build.
147
144
  }
@@ -0,0 +1,86 @@
1
+ package com.margelo.nitro.munimffmpeg
2
+
3
+ import androidx.annotation.Keep
4
+ import com.facebook.proguard.annotations.DoNotStrip
5
+
6
+ /**
7
+ * Thin wrapper over FFmpeg 9's own command-line tools, compiled to run inside
8
+ * the app process.
9
+ *
10
+ * fftools keeps its parsed command in file-scope globals, so exactly one
11
+ * execution runs at a time; the native core serialises callers and cancels
12
+ * anything still queued when [nativeCancel] is called.
13
+ */
14
+ @Keep
15
+ @DoNotStrip
16
+ object FFmpegNative {
17
+ init {
18
+ System.loadLibrary("munimffmpeg9")
19
+ }
20
+
21
+ /** Return code the tools report when a run was cancelled. */
22
+ const val CANCELLED = 255
23
+
24
+ external fun nativeVersion(): String
25
+
26
+ external fun nativeExecute(arguments: Array<String>, stdoutPath: String): Int
27
+
28
+ external fun nativeExecuteProbe(arguments: Array<String>, outputPath: String): Int
29
+
30
+ external fun nativeCancel()
31
+
32
+ data class Statistics(
33
+ val timeMs: Double,
34
+ val sizeBytes: Double,
35
+ val bitrateKbits: Double,
36
+ val speed: Double,
37
+ val videoFrameNumber: Double,
38
+ val fps: Double,
39
+ val quality: Double,
40
+ )
41
+
42
+ @Volatile
43
+ private var logSink: ((String) -> Unit)? = null
44
+
45
+ @Volatile
46
+ private var statisticsSink: ((Statistics) -> Unit)? = null
47
+
48
+ fun <T> withCallbacks(
49
+ onLog: ((String) -> Unit)?,
50
+ onStatistics: ((Statistics) -> Unit)?,
51
+ body: () -> T,
52
+ ): T {
53
+ logSink = onLog
54
+ statisticsSink = onStatistics
55
+ try {
56
+ return body()
57
+ } finally {
58
+ logSink = null
59
+ statisticsSink = null
60
+ }
61
+ }
62
+
63
+ @JvmStatic
64
+ @Keep
65
+ @DoNotStrip
66
+ fun onLog(message: String) {
67
+ logSink?.invoke(message)
68
+ }
69
+
70
+ @JvmStatic
71
+ @Keep
72
+ @DoNotStrip
73
+ fun onStatistics(
74
+ timeMs: Double,
75
+ sizeBytes: Double,
76
+ bitrateKbits: Double,
77
+ speed: Double,
78
+ videoFrameNumber: Double,
79
+ fps: Double,
80
+ quality: Double,
81
+ ) {
82
+ statisticsSink?.invoke(
83
+ Statistics(timeMs, sizeBytes, bitrateKbits, speed, videoFrameNumber, fps, quality)
84
+ )
85
+ }
86
+ }
@@ -1,19 +1,39 @@
1
1
  package com.margelo.nitro.munimffmpeg
2
2
 
3
3
  import androidx.annotation.Keep
4
- import com.arthenica.ffmpegkit.FFmpegKit
5
- import com.arthenica.ffmpegkit.FFmpegKitConfig
6
- import com.arthenica.ffmpegkit.FFprobeKit
7
- import com.arthenica.ffmpegkit.ReturnCode
8
- import com.arthenica.ffmpegkit.Session
9
4
  import com.facebook.proguard.annotations.DoNotStrip
10
5
  import com.margelo.nitro.core.Promise
6
+ import java.io.File
7
+ import java.util.concurrent.atomic.AtomicLong
11
8
 
12
9
  @Keep
13
10
  @DoNotStrip
14
11
  class HybridMunimFfmpeg : HybridMunimFfmpegSpec() {
15
12
  override val ffmpegVersion: String
16
- get() = FFmpegKitConfig.getFFmpegVersion()
13
+ get() = FFmpegNative.nativeVersion()
14
+
15
+ private val sessions = AtomicLong(0)
16
+
17
+ private fun nextSession() = sessions.incrementAndGet().toDouble()
18
+
19
+ private fun result(
20
+ sessionId: Double,
21
+ returnCode: Int,
22
+ output: String,
23
+ durationMs: Long,
24
+ ): FFmpegSessionResult {
25
+ val cancelled = returnCode == FFmpegNative.CANCELLED
26
+ return FFmpegSessionResult(
27
+ sessionId = sessionId,
28
+ returnCode = returnCode.toDouble(),
29
+ success = returnCode == 0,
30
+ cancelled = cancelled,
31
+ state = "completed",
32
+ durationMs = durationMs.toDouble(),
33
+ output = output,
34
+ failStackTrace = null,
35
+ )
36
+ }
17
37
 
18
38
  override fun execute(
19
39
  arguments_: Array<String>,
@@ -29,31 +49,42 @@ class HybridMunimFfmpeg : HybridMunimFfmpegSpec() {
29
49
  ) -> Unit)?,
30
50
  onSessionCreated: ((sessionId: Double) -> Unit)?,
31
51
  ): Promise<FFmpegSessionResult> {
32
- val promise = Promise<FFmpegSessionResult>()
52
+ val sessionId = nextSession()
53
+ onSessionCreated?.invoke(sessionId)
33
54
 
34
- try {
35
- val session = FFmpegKit.executeWithArgumentsAsync(
36
- arguments_,
37
- { session -> promise.resolve(session.toResult()) },
38
- { log -> onLog?.invoke(log.message) },
39
- { statistics ->
55
+ return Promise.async {
56
+ val output = StringBuilder()
57
+ val startedAt = System.currentTimeMillis()
58
+ // ffmpeg prints reports such as -encoders and -protocols to stdout rather
59
+ // than through its logger, so it is captured to a file and appended.
60
+ val stdout = File.createTempFile("munim-ffmpeg", ".txt")
61
+
62
+ val returnCode = FFmpegNative.withCallbacks(
63
+ onLog = { message ->
64
+ output.append(message)
65
+ onLog?.invoke(message)
66
+ },
67
+ onStatistics = { statistics ->
40
68
  onStatistics?.invoke(
41
- statistics.time,
42
- statistics.size.toDouble(),
43
- statistics.bitrate,
69
+ statistics.timeMs,
70
+ statistics.sizeBytes,
71
+ statistics.bitrateKbits,
44
72
  statistics.speed,
45
- statistics.videoFrameNumber.toDouble(),
46
- statistics.videoFps.toDouble(),
47
- statistics.videoQuality.toDouble(),
73
+ statistics.videoFrameNumber,
74
+ statistics.fps,
75
+ statistics.quality,
48
76
  )
49
77
  },
50
- )
51
- onSessionCreated?.invoke(session.sessionId.toDouble())
52
- } catch (error: Throwable) {
53
- promise.reject(error)
54
- }
78
+ ) {
79
+ FFmpegNative.nativeExecute(arguments_, stdout.absolutePath)
80
+ }
81
+
82
+ val printed = runCatching { stdout.readText() }.getOrDefault("")
83
+ stdout.delete()
84
+ output.append(printed)
55
85
 
56
- return promise
86
+ result(sessionId, returnCode, output.toString(), System.currentTimeMillis() - startedAt)
87
+ }
57
88
  }
58
89
 
59
90
  override fun probe(
@@ -61,25 +92,19 @@ class HybridMunimFfmpeg : HybridMunimFfmpegSpec() {
61
92
  onLog: ((message: String) -> Unit)?,
62
93
  onSessionCreated: ((sessionId: Double) -> Unit)?,
63
94
  ): Promise<FFmpegSessionResult> {
64
- val promise = Promise<FFmpegSessionResult>()
95
+ val sessionId = nextSession()
96
+ onSessionCreated?.invoke(sessionId)
65
97
 
66
- try {
67
- val session = FFprobeKit.executeWithArgumentsAsync(
68
- arguments_,
69
- { session -> promise.resolve(session.toResult()) },
70
- { log -> onLog?.invoke(log.message) },
71
- )
72
- onSessionCreated?.invoke(session.sessionId.toDouble())
73
- } catch (error: Throwable) {
74
- promise.reject(error)
98
+ return Promise.async {
99
+ val startedAt = System.currentTimeMillis()
100
+ val (returnCode, report) = runProbe(arguments_, onLog)
101
+ result(sessionId, returnCode, report, System.currentTimeMillis() - startedAt)
75
102
  }
76
-
77
- return promise
78
103
  }
79
104
 
80
105
  override fun getMediaInformation(path: String): Promise<String> {
81
106
  return Promise.async {
82
- val session = FFprobeKit.executeWithArguments(
107
+ val (returnCode, report) = runProbe(
83
108
  arrayOf(
84
109
  "-v",
85
110
  "error",
@@ -90,25 +115,49 @@ class HybridMunimFfmpeg : HybridMunimFfmpegSpec() {
90
115
  "-show_chapters",
91
116
  path,
92
117
  ),
118
+ null,
93
119
  )
94
- val returnCode = session.returnCode
95
120
 
96
- if (!ReturnCode.isSuccess(returnCode)) {
121
+ if (returnCode != 0) {
97
122
  throw IllegalStateException(
98
- session.failStackTrace ?: session.output.ifEmpty {
99
- "FFprobe failed with return code ${returnCode?.value ?: -1}"
100
- },
123
+ report.ifEmpty { "FFprobe failed with return code $returnCode" }
101
124
  )
102
125
  }
103
126
 
104
- session.output
127
+ report
128
+ }
129
+ }
130
+
131
+ /**
132
+ * ffprobe writes its report to stdout, which is not reachable from an app, so
133
+ * it is pointed at a temporary file with `-o` and read back.
134
+ */
135
+ private fun runProbe(
136
+ arguments: Array<String>,
137
+ onLog: ((message: String) -> Unit)?,
138
+ ): Pair<Int, String> {
139
+ val destination = File.createTempFile("munim-ffprobe", ".txt")
140
+ try {
141
+ val logs = StringBuilder()
142
+ val returnCode = FFmpegNative.withCallbacks(
143
+ onLog = { message ->
144
+ logs.append(message)
145
+ onLog?.invoke(message)
146
+ },
147
+ onStatistics = null,
148
+ ) {
149
+ FFmpegNative.nativeExecuteProbe(arguments, destination.absolutePath)
150
+ }
151
+
152
+ val report = destination.readText()
153
+ return returnCode to report.ifEmpty { logs.toString() }
154
+ } finally {
155
+ destination.delete()
105
156
  }
106
157
  }
107
158
 
108
159
  override fun cancel(sessionId: Double?) {
109
- if (sessionId == null) {
110
- FFmpegKit.cancel()
111
- } else {
160
+ if (sessionId != null) {
112
161
  require(
113
162
  sessionId.isFinite() &&
114
163
  sessionId > 0 &&
@@ -117,25 +166,13 @@ class HybridMunimFfmpeg : HybridMunimFfmpegSpec() {
117
166
  ) {
118
167
  "Invalid FFmpeg session ID: $sessionId. Expected a positive safe integer."
119
168
  }
120
- FFmpegKit.cancel(sessionId.toLong())
121
169
  }
170
+ // Only one execution runs at a time, so a targeted cancel and cancelAll()
171
+ // are the same operation.
172
+ FFmpegNative.nativeCancel()
122
173
  }
123
174
 
124
175
  override fun cancelAll() {
125
- FFmpegKit.cancel()
126
- }
127
-
128
- private fun Session.toResult(): FFmpegSessionResult {
129
- val code = returnCode
130
- return FFmpegSessionResult(
131
- sessionId = sessionId.toDouble(),
132
- returnCode = (code?.value ?: -1).toDouble(),
133
- success = ReturnCode.isSuccess(code),
134
- cancelled = ReturnCode.isCancel(code),
135
- state = state.name.lowercase(),
136
- durationMs = duration.toDouble(),
137
- output = output,
138
- failStackTrace = failStackTrace,
139
- )
176
+ FFmpegNative.nativeCancel()
140
177
  }
141
178
  }
package/ios/Bridge.h CHANGED
@@ -2,7 +2,10 @@
2
2
  // Bridge.h
3
3
  // NitroMunimFfmpeg
4
4
  //
5
- // Created by Marc Rousavy on 22.07.24.
5
+ // Objective-C bridging header: exposes the C core that drives FFmpeg 9's
6
+ // in-process command-line tools to the Swift implementation.
6
7
  //
7
8
 
8
9
  #pragma once
10
+
11
+ #include "munim_ffmpeg_core.h"
@@ -1,9 +1,75 @@
1
+ import Foundation
1
2
  import NitroModules
2
- import ffmpegkit
3
+
4
+ /// Callbacks for the execution currently holding the core's lock. The core
5
+ /// serialises executions, so a single slot is enough.
6
+ private final class ActiveSession {
7
+ var onLog: ((String) -> Void)?
8
+ var onStatistics: ((Double, Double, Double, Double, Double, Double, Double) -> Void)?
9
+ var output = ""
10
+
11
+ func reset() {
12
+ onLog = nil
13
+ onStatistics = nil
14
+ output = ""
15
+ }
16
+ }
17
+
18
+ private let active = ActiveSession()
19
+ // Concurrent on purpose: the C core serialises executions behind its own lock,
20
+ // and a request that is waiting there can still be cancelled. A serial queue
21
+ // would hold the second call outside the core, where cancelAll() cannot see it.
22
+ private let executionQueue = DispatchQueue(
23
+ label: "com.munimtech.ffmpeg",
24
+ qos: .userInitiated,
25
+ attributes: .concurrent
26
+ )
27
+
28
+ private func installCallbacks() {
29
+ munim_ffmpeg_set_callbacks({ _, message in
30
+ guard let message else { return }
31
+ let text = String(cString: message)
32
+ active.output += text
33
+ active.onLog?(text)
34
+ }, { _, timeMs, sizeBytes, bitrate, speed, frame, fps, quality in
35
+ active.onStatistics?(timeMs, sizeBytes, bitrate, speed, frame, fps, quality)
36
+ }, nil)
37
+ }
3
38
 
4
39
  final class HybridMunimFfmpeg: HybridMunimFfmpegSpec {
40
+ private var sessionCounter: Double = 0
41
+
5
42
  var ffmpegVersion: String {
6
- FFmpegKitConfig.getFFmpegVersion()
43
+ String(cString: munim_ffmpeg_version())
44
+ }
45
+
46
+ private func nextSession() -> Double {
47
+ sessionCounter += 1
48
+ return sessionCounter
49
+ }
50
+
51
+ private static func temporaryFile() -> String {
52
+ FileManager.default.temporaryDirectory
53
+ .appendingPathComponent("munim-ffmpeg-\(UUID().uuidString)")
54
+ .path
55
+ }
56
+
57
+ private func result(
58
+ sessionId: Double,
59
+ returnCode: Int32,
60
+ output: String,
61
+ startedAt: Date
62
+ ) -> FFmpegSessionResult {
63
+ FFmpegSessionResult(
64
+ sessionId: sessionId,
65
+ returnCode: Double(returnCode),
66
+ success: returnCode == 0,
67
+ cancelled: returnCode == Int32(MUNIM_FFMPEG_CANCELLED),
68
+ state: "completed",
69
+ durationMs: Date().timeIntervalSince(startedAt) * 1000,
70
+ output: output,
71
+ failStackTrace: nil
72
+ )
7
73
  }
8
74
 
9
75
  func execute(
@@ -13,35 +79,38 @@ final class HybridMunimFfmpeg: HybridMunimFfmpegSpec {
13
79
  onSessionCreated: ((_ sessionId: Double) -> Void)?
14
80
  ) throws -> Promise<FFmpegSessionResult> {
15
81
  let promise = Promise<FFmpegSessionResult>()
82
+ let sessionId = nextSession()
83
+ onSessionCreated?(sessionId)
84
+
85
+ executionQueue.async {
86
+ let startedAt = Date()
87
+ // ffmpeg prints reports such as -encoders to stdout rather than through
88
+ // its logger, so it is captured to a file and appended to the output.
89
+ let printedPath = Self.temporaryFile()
90
+
91
+ installCallbacks()
92
+ active.reset()
93
+ active.onLog = onLog
94
+ active.onStatistics = onStatistics
95
+
96
+ let returnCode = withArrayOfCStrings(arguments_) { argv in
97
+ munim_ffmpeg_execute(Int32(arguments_.count), argv, printedPath)
98
+ }
99
+
100
+ let printed = (try? String(contentsOfFile: printedPath, encoding: .utf8)) ?? ""
101
+ try? FileManager.default.removeItem(atPath: printedPath)
16
102
 
17
- let session = FFmpegKit.execute(
18
- withArgumentsAsync: arguments_,
19
- withCompleteCallback: { session in
20
- guard let session else {
21
- promise.reject(withError: MunimFfmpegError.missingSession)
22
- return
23
- }
24
- promise.resolve(withResult: Self.result(from: session))
25
- },
26
- withLogCallback: { log in
27
- guard let message = log?.getMessage() else { return }
28
- onLog?(message)
29
- },
30
- withStatisticsCallback: { statistics in
31
- guard let statistics else { return }
32
- onStatistics?(
33
- statistics.getTime(),
34
- Double(statistics.getSize()),
35
- statistics.getBitrate(),
36
- statistics.getSpeed(),
37
- Double(statistics.getVideoFrameNumber()),
38
- Double(statistics.getVideoFps()),
39
- Double(statistics.getVideoQuality())
103
+ let output = active.output + printed
104
+ active.reset()
105
+
106
+ promise.resolve(
107
+ withResult: self.result(
108
+ sessionId: sessionId,
109
+ returnCode: returnCode,
110
+ output: output,
111
+ startedAt: startedAt
40
112
  )
41
- }
42
- )
43
- if let session {
44
- onSessionCreated?(Double(session.getId()))
113
+ )
45
114
  }
46
115
 
47
116
  return promise
@@ -53,23 +122,20 @@ final class HybridMunimFfmpeg: HybridMunimFfmpegSpec {
53
122
  onSessionCreated: ((_ sessionId: Double) -> Void)?
54
123
  ) throws -> Promise<FFmpegSessionResult> {
55
124
  let promise = Promise<FFmpegSessionResult>()
56
-
57
- let session = FFprobeKit.execute(
58
- withArgumentsAsync: arguments_,
59
- withCompleteCallback: { session in
60
- guard let session else {
61
- promise.reject(withError: MunimFfmpegError.missingSession)
62
- return
63
- }
64
- promise.resolve(withResult: Self.result(from: session))
65
- },
66
- withLogCallback: { log in
67
- guard let message = log?.getMessage() else { return }
68
- onLog?(message)
69
- }
70
- )
71
- if let session {
72
- onSessionCreated?(Double(session.getId()))
125
+ let sessionId = nextSession()
126
+ onSessionCreated?(sessionId)
127
+
128
+ executionQueue.async {
129
+ let startedAt = Date()
130
+ let (returnCode, report) = Self.runProbe(arguments_, onLog: onLog)
131
+ promise.resolve(
132
+ withResult: self.result(
133
+ sessionId: sessionId,
134
+ returnCode: returnCode,
135
+ output: report,
136
+ startedAt: startedAt
137
+ )
138
+ )
73
139
  }
74
140
 
75
141
  return promise
@@ -77,37 +143,58 @@ final class HybridMunimFfmpeg: HybridMunimFfmpegSpec {
77
143
 
78
144
  func getMediaInformation(path: String) throws -> Promise<String> {
79
145
  let promise = Promise<String>()
80
- let arguments = [
81
- "-v",
82
- "error",
83
- "-print_format",
84
- "json",
85
- "-show_format",
86
- "-show_streams",
87
- "-show_chapters",
88
- path,
89
- ]
90
-
91
- FFprobeKit.execute(
92
- withArgumentsAsync: arguments,
93
- withCompleteCallback: { session in
94
- guard let session else {
95
- promise.reject(withError: MunimFfmpegError.missingSession)
96
- return
97
- }
98
- let returnCode = session.getReturnCode()
99
- guard ReturnCode.isSuccess(returnCode) else {
100
- let message = session.getFailStackTrace() ?? session.getOutput() ?? "FFprobe failed"
101
- promise.reject(withError: MunimFfmpegError.executionFailed(message))
102
- return
103
- }
104
- promise.resolve(withResult: session.getOutput() ?? "{}")
146
+
147
+ executionQueue.async {
148
+ let (returnCode, report) = Self.runProbe(
149
+ [
150
+ "-v", "error",
151
+ "-print_format", "json",
152
+ "-show_format", "-show_streams", "-show_chapters",
153
+ path,
154
+ ],
155
+ onLog: nil
156
+ )
157
+
158
+ if returnCode != 0 {
159
+ promise.reject(
160
+ withError: MunimFfmpegError.executionFailed(
161
+ report.isEmpty ? "FFprobe failed with return code \(returnCode)" : report
162
+ )
163
+ )
164
+ return
105
165
  }
106
- )
166
+
167
+ promise.resolve(withResult: report)
168
+ }
107
169
 
108
170
  return promise
109
171
  }
110
172
 
173
+ /// ffprobe writes its report to stdout, which is not reachable from an app,
174
+ /// so it is pointed at a temporary file with `-o` and read back.
175
+ private static func runProbe(
176
+ _ arguments: [String],
177
+ onLog: ((String) -> Void)?
178
+ ) -> (Int32, String) {
179
+ let destination = temporaryFile()
180
+
181
+ installCallbacks()
182
+ active.reset()
183
+ active.onLog = onLog
184
+
185
+ let returnCode = withArrayOfCStrings(arguments) { argv in
186
+ munim_ffmpeg_probe(Int32(arguments.count), argv, destination)
187
+ }
188
+
189
+ let report = (try? String(contentsOfFile: destination, encoding: .utf8)) ?? ""
190
+ try? FileManager.default.removeItem(atPath: destination)
191
+
192
+ let logs = active.output
193
+ active.reset()
194
+
195
+ return (returnCode, report.isEmpty ? logs : report)
196
+ }
197
+
111
198
  func cancel(sessionId: Double?) throws {
112
199
  if let sessionId {
113
200
  guard
@@ -118,52 +205,39 @@ final class HybridMunimFfmpeg: HybridMunimFfmpegSpec {
118
205
  else {
119
206
  throw MunimFfmpegError.invalidSessionId(sessionId)
120
207
  }
121
- FFmpegKit.cancel(Int(sessionId))
122
- } else {
123
- FFmpegKit.cancel()
124
208
  }
209
+ // One execution runs at a time, so cancelling a specific session and
210
+ // cancelling everything are the same operation.
211
+ munim_ffmpeg_cancel()
125
212
  }
126
213
 
127
214
  func cancelAll() throws {
128
- FFmpegKit.cancel()
129
- }
130
-
131
- // `String(describing:)` on the imported NS_ENUM yields "sessionstate(rawvalue: 3)".
132
- // Android reports the Java enum name, so map to the same lowercase names here.
133
- private static func stateName(_ state: SessionState) -> String {
134
- switch state {
135
- case .created: return "created"
136
- case .running: return "running"
137
- case .failed: return "failed"
138
- case .completed: return "completed"
139
- @unknown default: return "unknown"
140
- }
215
+ munim_ffmpeg_cancel()
141
216
  }
217
+ }
142
218
 
143
- private static func result(from session: Session) -> FFmpegSessionResult {
144
- let returnCode = session.getReturnCode()
145
- return FFmpegSessionResult(
146
- sessionId: Double(session.getId()),
147
- returnCode: Double(returnCode?.getValue() ?? -1),
148
- success: ReturnCode.isSuccess(returnCode),
149
- cancelled: ReturnCode.isCancel(returnCode),
150
- state: Self.stateName(session.getState()),
151
- durationMs: Double(session.getDuration()),
152
- output: session.getOutput() ?? "",
153
- failStackTrace: session.getFailStackTrace()
154
- )
219
+ /// Builds a C `argv` that stays valid for the duration of `body`.
220
+ private func withArrayOfCStrings<R>(
221
+ _ values: [String],
222
+ _ body: (UnsafePointer<UnsafePointer<CChar>?>?) -> R
223
+ ) -> R {
224
+ var pointers = values.map { strdup($0) }
225
+ defer { pointers.forEach { free($0) } }
226
+
227
+ return pointers.withUnsafeMutableBufferPointer { buffer in
228
+ buffer.baseAddress!.withMemoryRebound(
229
+ to: UnsafePointer<CChar>?.self,
230
+ capacity: buffer.count
231
+ ) { body($0) }
155
232
  }
156
233
  }
157
234
 
158
235
  private enum MunimFfmpegError: LocalizedError {
159
- case missingSession
160
236
  case executionFailed(String)
161
237
  case invalidSessionId(Double)
162
238
 
163
239
  var errorDescription: String? {
164
240
  switch self {
165
- case .missingSession:
166
- return "FFmpegKit did not return a session."
167
241
  case .executionFailed(let message):
168
242
  return message
169
243
  case .invalidSessionId(let sessionId):
@@ -0,0 +1,58 @@
1
+ /*
2
+ * Platform-neutral core for running FFmpeg 9's own command-line tools inside an
3
+ * app process. The Android JNI bridge and the iOS Swift layer both sit on top
4
+ * of this.
5
+ */
6
+ #ifndef MUNIM_FFMPEG_CORE_H
7
+ #define MUNIM_FFMPEG_CORE_H
8
+
9
+ #ifdef __cplusplus
10
+ extern "C" {
11
+ #endif
12
+
13
+ typedef void (*munim_log_callback)(void *context, const char *message);
14
+
15
+ typedef void (*munim_statistics_callback)(void *context, double time_ms,
16
+ double size_bytes,
17
+ double bitrate_kbits, double speed,
18
+ double video_frame_number, double fps,
19
+ double quality);
20
+
21
+ /** Version string of the linked FFmpeg, e.g. "9.0.1". */
22
+ const char *munim_ffmpeg_version(void);
23
+
24
+ /** Callbacks apply to whichever execution is currently running. */
25
+ void munim_ffmpeg_set_callbacks(munim_log_callback on_log,
26
+ munim_statistics_callback on_statistics,
27
+ void *context);
28
+
29
+ /**
30
+ * Runs `ffmpeg` with the given arguments; `argv[0]` is supplied internally.
31
+ *
32
+ * `stdout_path` receives anything the tool prints rather than logs, such as the
33
+ * `-encoders` and `-protocols` reports. Pass NULL to discard it.
34
+ *
35
+ * fftools keeps its parsed command in file-scope globals, so calls are
36
+ * serialised: a second execution waits for the first to finish.
37
+ */
38
+ int munim_ffmpeg_execute(int argc, const char *const *argv,
39
+ const char *stdout_path);
40
+
41
+ /** Runs `ffprobe`, writing its report to `output_path` via `-o`. */
42
+ int munim_ffmpeg_probe(int argc, const char *const *argv,
43
+ const char *output_path);
44
+
45
+ /**
46
+ * Requests cancellation of the running execution, and of any execution already
47
+ * queued behind it.
48
+ */
49
+ void munim_ffmpeg_cancel(void);
50
+
51
+ /** Return code reported when a run was cancelled. */
52
+ #define MUNIM_FFMPEG_CANCELLED 255
53
+
54
+ #ifdef __cplusplus
55
+ }
56
+ #endif
57
+
58
+ #endif /* MUNIM_FFMPEG_CORE_H */
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "munim-ffmpeg",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Fast FFmpeg and FFprobe for Expo and React Native, powered by Nitro Modules",
5
5
  "main": "lib/index",
6
6
  "module": "lib/index",
7
7
  "types": "lib/index.d.ts",
8
+ "bin": {
9
+ "munim-ffmpeg-fetch-binaries": "scripts/fetch-binaries.mjs"
10
+ },
8
11
  "react-native": "src/index",
9
12
  "source": "src/index",
10
13
  "files": [
@@ -16,7 +19,7 @@
16
19
  "android/gradle.properties",
17
20
  "android/fix-prefab.gradle",
18
21
  "android/CMakeLists.txt",
19
- "android/src",
22
+ "android/src/main/java",
20
23
  "cpp",
21
24
  "ios/**/*.h",
22
25
  "ios/**/*.m",
@@ -28,7 +31,10 @@
28
31
  "*.podspec",
29
32
  "README.md",
30
33
  "LICENSE",
31
- "scripts/patch-ffmpegkit-level.rb"
34
+ "android/src/main/cpp",
35
+ "android/src/main/AndroidManifest.xml",
36
+ "scripts/fetch-binaries.mjs",
37
+ "scripts/binaries.json"
32
38
  ],
33
39
  "scripts": {
34
40
  "typecheck": "tsc --noEmit",
@@ -44,7 +50,10 @@
44
50
  "example:ios": "npm --workspace example run ios",
45
51
  "example:android": "npm --workspace example run android",
46
52
  "release:local": "node scripts/release-local.mjs",
47
- "format": "prettier --write \"**/*.{ts,tsx,js,cjs,mjs,json,md}\""
53
+ "format": "prettier --write \"**/*.{ts,tsx,js,cjs,mjs,json,md}\"",
54
+ "postinstall": "node scripts/fetch-binaries.mjs",
55
+ "binaries:build": "scripts/ffmpeg/fetch-source.sh && scripts/ffmpeg/build-all.sh",
56
+ "binaries:package": "scripts/ffmpeg/package.sh"
48
57
  },
49
58
  "keywords": [
50
59
  "react-native",
@@ -0,0 +1,5 @@
1
+ {
2
+ "archive": "munim-ffmpeg-binaries.tar.gz",
3
+ "sha256": "5d3a286633c584ffb2ef9dec091695f5ba8f8db88187058df7143ef258e15beb",
4
+ "ffmpeg": "9.0.1"
5
+ }
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Downloads the prebuilt FFmpeg binaries for this version of munim-ffmpeg.
4
+ *
5
+ * They are not published to npm: the bundle is ~200 MB of static libraries and
6
+ * shared objects, which does not belong in a registry tarball. It ships as a
7
+ * GitHub release asset instead, pinned by the checksum in scripts/binaries.json
8
+ * so a build cannot silently pick up different bytes.
9
+ *
10
+ * Runs from postinstall, and can be re-run by hand:
11
+ * npx munim-ffmpeg-fetch-binaries
12
+ */
13
+ import { createHash } from 'node:crypto'
14
+ import { createWriteStream } from 'node:fs'
15
+ import { mkdir, readFile, rm, stat } from 'node:fs/promises'
16
+ import { createRequire } from 'node:module'
17
+ import path from 'node:path'
18
+ import { pipeline } from 'node:stream/promises'
19
+ import { fileURLToPath } from 'node:url'
20
+ import { execFile } from 'node:child_process'
21
+ import { promisify } from 'node:util'
22
+
23
+ const run = promisify(execFile)
24
+ const require = createRequire(import.meta.url)
25
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
26
+
27
+ const REPOSITORY = 'munimtechnologies/munim-ffmpeg'
28
+ const MARKERS = [
29
+ 'ios/MunimFFmpeg.xcframework',
30
+ 'android/src/main/jniLibs/arm64-v8a/libmunimffmpeg9.so',
31
+ ]
32
+
33
+ async function exists(target) {
34
+ try {
35
+ await stat(target)
36
+ return true
37
+ } catch {
38
+ return false
39
+ }
40
+ }
41
+
42
+ async function alreadyInstalled() {
43
+ for (const marker of MARKERS) {
44
+ if (!(await exists(path.join(root, marker)))) return false
45
+ }
46
+ return true
47
+ }
48
+
49
+ async function download(url, destination) {
50
+ const response = await fetch(url, { redirect: 'follow' })
51
+ if (!response.ok) {
52
+ throw new Error(`${response.status} ${response.statusText} for ${url}`)
53
+ }
54
+ await pipeline(response.body, createWriteStream(destination))
55
+ }
56
+
57
+ async function checksum(file) {
58
+ const hash = createHash('sha256')
59
+ hash.update(await readFile(file))
60
+ return hash.digest('hex')
61
+ }
62
+
63
+ async function main() {
64
+ const manifest = require(path.join(root, 'scripts', 'binaries.json'))
65
+ const { version } = require(path.join(root, 'package.json'))
66
+
67
+ if (await alreadyInstalled()) {
68
+ console.log(
69
+ `munim-ffmpeg: FFmpeg ${manifest.ffmpeg} binaries already present`
70
+ )
71
+ return
72
+ }
73
+
74
+ const url =
75
+ process.env.MUNIM_FFMPEG_BINARIES_URL ??
76
+ `https://github.com/${REPOSITORY}/releases/download/v${version}/${manifest.archive}`
77
+
78
+ const cache = path.join(root, '.binaries-cache')
79
+ await mkdir(cache, { recursive: true })
80
+ const archive = path.join(cache, manifest.archive)
81
+
82
+ console.log(`munim-ffmpeg: downloading FFmpeg ${manifest.ffmpeg} binaries…`)
83
+ await download(url, archive)
84
+
85
+ const actual = await checksum(archive)
86
+ if (actual !== manifest.sha256) {
87
+ await rm(cache, { force: true, recursive: true })
88
+ throw new Error(
89
+ `checksum mismatch for ${manifest.archive}\n expected ${manifest.sha256}\n received ${actual}`
90
+ )
91
+ }
92
+
93
+ await run('tar', ['xzf', archive, '-C', root])
94
+ await rm(cache, { force: true, recursive: true })
95
+
96
+ if (!(await alreadyInstalled())) {
97
+ throw new Error('the archive did not contain the expected binaries')
98
+ }
99
+
100
+ console.log(`munim-ffmpeg: FFmpeg ${manifest.ffmpeg} binaries installed`)
101
+ }
102
+
103
+ main().catch((error) => {
104
+ console.error(`
105
+ munim-ffmpeg: could not install the native FFmpeg binaries.
106
+
107
+ ${error.message}
108
+
109
+ The package cannot build without them. Options:
110
+ β€’ re-run: npx munim-ffmpeg-fetch-binaries
111
+ β€’ use a mirror: MUNIM_FFMPEG_BINARIES_URL=<url> npx munim-ffmpeg-fetch-binaries
112
+ β€’ build them: see scripts/ffmpeg/README.md
113
+ `)
114
+ process.exitCode = 1
115
+ })
@@ -1,43 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'fileutils'
4
- require 'tempfile'
5
-
6
- # FFmpegKit 6.0 declares `Level` with an unsigned backing type while assigning
7
- # negative values to it. Clang accepts that in Objective-C, but Xcode 26 rejects
8
- # the header once Nitro turns on Swift/C++ interoperability, so every vendored
9
- # and copied copy of Level.h is rewritten to a signed enum before compiling.
10
- #
11
- # The header is located by globbing rather than by pod name: the FFmpegKit
12
- # republishes lay their xcframeworks out differently (`<pod>/ffmpegkit.xcframework`
13
- # versus `<pod>/xcframeworks/ffmpegkit.xcframework`), and the pod itself may be
14
- # swapped for another variant.
15
- pods_root = ENV.fetch('PODS_ROOT')
16
- build_products = ENV.fetch('PODS_CONFIGURATION_BUILD_DIR')
17
-
18
- headers = [pods_root, build_products]
19
- .flat_map { |root| Dir.glob(File.join(root, '**', 'ffmpegkit.framework', 'Headers', 'Level.h')) }
20
- .uniq
21
- .select { |path| File.file?(path) }
22
-
23
- abort('munim-ffmpeg: could not find the expected FFmpegKit Level.h') if headers.empty?
24
-
25
- unsigned_declaration = 'NS_ENUM(NSUInteger, Level)'
26
- signed_declaration = 'NS_ENUM(NSInteger, Level)'
27
-
28
- headers.each do |header|
29
- contents = File.binread(header)
30
- next if contents.include?(signed_declaration) && !contents.include?(unsigned_declaration)
31
-
32
- occurrences = contents.scan(unsigned_declaration).length
33
- abort("munim-ffmpeg: unexpected Level enum declaration in #{header}") unless occurrences == 1
34
-
35
- patched = contents.sub(unsigned_declaration, signed_declaration)
36
- Tempfile.create(['Level', '.h'], File.dirname(header)) do |temporary|
37
- temporary.binmode
38
- temporary.write(patched)
39
- temporary.flush
40
- FileUtils.chmod(File.stat(header).mode, temporary.path)
41
- FileUtils.mv(temporary.path, header)
42
- end
43
- end