munim-ffmpeg 0.1.1 โ†’ 0.2.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.
@@ -27,10 +27,11 @@ Pod::Spec.new do |s|
27
27
 
28
28
  s.dependency 'React-jsi'
29
29
  s.dependency 'React-callinvoker'
30
- s.dependency 'ffmpeg-kit-ios-https-alt', '6.0'
30
+ s.dependency 'ffmpeg-kit-ios-full-gpl-alt', '6.0'
31
31
 
32
32
  # ffmpeg-kit 6.0 declares negative Level values with an unsigned backing type.
33
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.
34
35
  s.script_phase = {
35
36
  :name => 'Patch FFmpegKit Level enum for Xcode 26',
36
37
  :script => '"${RUBY_EXECUTABLE:-/usr/bin/ruby}" "${PODS_TARGET_SRCROOT}/scripts/patch-ffmpegkit-level.rb"',
package/README.md CHANGED
@@ -61,19 +61,20 @@
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 Android artifact is GPL-enabled and includes x264/x265; distributing it can trigger GPLv3 obligations. The native dependencies and FFmpeg retain their own licenses. Review [Native dependencies and licensing](#native-dependencies-and-licensing) before distributing an app.
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.
65
65
 
66
66
  ## Table of contents
67
67
 
68
68
  - [๐Ÿ“š Documentation](#-documentation)
69
69
  - [๐Ÿš€ Features](#-features)
70
70
  - [Platform support matrix](#platform-support-matrix)
71
+ - [Bundled FFmpeg builds](#bundled-ffmpeg-builds)
71
72
  - [๐Ÿ“ฆ Installation](#-installation)
72
73
  - [Working with media paths](#working-with-media-paths)
73
74
  - [โšก Quick start](#-quick-start)
74
75
  - [๐Ÿ”ง API reference](#-api-reference)
75
76
  - [๐Ÿ“– Usage examples](#-usage-examples)
76
- - [Native dependencies and licensing](#native-dependencies-and-licensing)
77
+ - [Licensing](#licensing)
77
78
  - [๐Ÿ” Troubleshooting](#-troubleshooting)
78
79
  - [Development](#development)
79
80
  - [๐Ÿ‘ Contributing](#-contributing)
@@ -109,6 +110,7 @@
109
110
  - ๐Ÿ“ฑ **iOS and Android:** Native implementations in Swift and Kotlin
110
111
  - ๐Ÿงฌ **Nitro Modules:** Generated high-performance native bindings
111
112
  - ๐Ÿš€ **Expo compatible:** Autolinking, config plugin, and an Expo development example
113
+ - ๐Ÿงช **Capability discovery:** Ask the bundled build which encoders and decoders it actually has
112
114
  - ๐ŸŽฏ **TypeScript:** Complete public callback and result types
113
115
  - ๐Ÿ—‚๏ธ **16 KB Android support:** Uses a maintained FFmpegKit-compatible Android artifact with 16 KB page-size support
114
116
 
@@ -125,9 +127,61 @@
125
127
  | Cancel one FFmpeg session | โœ… | โœ… | Pass the positive safe-integer ID received by `execute`'s `onSessionCreated`. The native dependency does not expose FFprobe cancellation. |
126
128
  | Cancel all FFmpeg sessions | โœ… | โœ… | Use `cancelAll()` or call `cancel()` without an ID. |
127
129
  | Expo Go | โŒ | โŒ | A native development build is required. |
128
- | Remote HTTP(S) inputs | Build-dependent | Build-dependent | The bundled variants include HTTPS support, but remote server behavior and protocol support can vary. Prefer local files for predictable app workflows. |
130
+ | Capability discovery | โœ… | โœ… | `listEncoders()`, `listDecoders()`, and `pickEncoder()` report what the bundled build supports. |
131
+ | H.264 encoding | VideoToolbox | libx264 | The builds differ; use `pickEncoder(['libx264', 'h264_videotoolbox'])` instead of hard-coding an encoder. |
132
+ | Remote HTTP(S) inputs | โœ… | โœ… | Both builds link GnuTLS. Remote server behaviour still varies; prefer local files for predictable app workflows. |
129
133
 
130
- Codec availability is determined by the native FFmpeg builds listed below. Do not assume every FFmpeg codec or external library is bundled.
134
+ Codec availability is determined by the native FFmpeg builds described in [Bundled FFmpeg builds](#bundled-ffmpeg-builds). Do not assume every FFmpeg codec or external library is present.
135
+
136
+ ## Bundled FFmpeg builds
137
+
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
+
140
+ | | iOS | Android |
141
+ | --- | --- | --- |
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** |
145
+ | Hardware codecs | VideoToolbox, AudioToolbox | MediaCodec |
146
+ | 16 KB page size | n/a | โœ… (required by Google Play) |
147
+
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
+
150
+ ### Encoders
151
+
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`.
153
+
154
+ The differences that matter:
155
+
156
+ | Encoder | iOS | Android |
157
+ | --- | --- | --- |
158
+ | `libx264` / `libx264rgb` (H.264, software) | โŒ | โœ… |
159
+ | `libx265` (HEVC, software) | โŒ | โœ… |
160
+ | `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.
168
+
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`:
170
+
171
+ ```typescript
172
+ import { execute, pickEncoder } from 'munim-ffmpeg'
173
+
174
+ const h264 = await pickEncoder(['libx264', 'h264_videotoolbox'])
175
+ if (!h264) throw new Error('No H.264 encoder in this build')
176
+
177
+ await execute(['-y', '-i', inputPath, '-c:v', h264, outputPath])
178
+ ```
179
+
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.
181
+
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"`.
131
185
 
132
186
  ## ๐Ÿ“ฆ Installation
133
187
 
@@ -205,7 +259,7 @@ No camera, microphone, photo-library, or storage permission is added automatical
205
259
  FFmpeg runs natively and needs a path or URI the native process can access.
206
260
 
207
261
  - Prefer files inside your app's document, cache, or temporary directory.
208
- - `file://` URIs and plain local paths are the most predictable inputs and outputs.
262
+ - `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`.
209
263
  - On Android, copy a `content://` document into application storage before processing when the native library cannot open it directly.
210
264
  - Copy photo-library or document-picker assets when the provider gives temporary or security-scoped access.
211
265
  - Ensure the output directory already exists.
@@ -343,6 +397,44 @@ Returns the version reported by the bundled native FFmpeg library.
343
397
  function getFFmpegVersion(): string
344
398
  ```
345
399
 
400
+ ### `listEncoders()`
401
+
402
+ Returns the encoder names the bundled FFmpeg build can write. The result is cached after the first call.
403
+
404
+ ```typescript
405
+ function listEncoders(): Promise<string[]>
406
+ ```
407
+
408
+ ### `listDecoders()`
409
+
410
+ Returns the decoder names the bundled FFmpeg build can read.
411
+
412
+ ```typescript
413
+ function listDecoders(): Promise<string[]>
414
+ ```
415
+
416
+ ### `pickEncoder(candidates)`
417
+
418
+ 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.
419
+
420
+ ```typescript
421
+ function pickEncoder(candidates: string[]): Promise<string | undefined>
422
+ ```
423
+
424
+ ```typescript
425
+ const hevc = await pickEncoder(['libx265', 'hevc_videotoolbox'])
426
+ ```
427
+
428
+ ### `normalizePath(value)`
429
+
430
+ Converts a `file://` URI into the plain path FFmpeg expects, and returns anything else untouched.
431
+
432
+ ```typescript
433
+ function normalizePath(value: string): string
434
+ ```
435
+
436
+ `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.
437
+
346
438
  ### `FFmpegSessionResult`
347
439
 
348
440
  ```typescript
@@ -358,6 +450,8 @@ type FFmpegSessionResult = {
358
450
  }
359
451
  ```
360
452
 
453
+ `state` is one of `created`, `running`, `failed`, or `completed`, and reports the same values on both platforms.
454
+
361
455
  Always check `success` or `cancelled`; Promise resolution means the native session completed, not necessarily that FFmpeg returned a success code.
362
456
 
363
457
  ## ๐Ÿ“– Usage examples
@@ -391,6 +485,34 @@ if (!result.success) {
391
485
  }
392
486
  ```
393
487
 
488
+ ### Transcode to H.264 on both platforms
489
+
490
+ ```typescript
491
+ import { execute, pickEncoder } from 'munim-ffmpeg'
492
+
493
+ const encoder = await pickEncoder(['libx264', 'h264_videotoolbox'])
494
+ if (!encoder) throw new Error('No H.264 encoder available in this build')
495
+
496
+ // -preset is an x264 option; VideoToolbox rejects it.
497
+ const quality = encoder === 'libx264' ? ['-preset', 'veryfast', '-crf', '23'] : ['-b:v', '2M']
498
+
499
+ const result = await execute([
500
+ '-y',
501
+ '-i',
502
+ inputPath,
503
+ '-c:v',
504
+ encoder,
505
+ ...quality,
506
+ '-c:a',
507
+ 'aac',
508
+ '-pix_fmt',
509
+ 'yuv420p',
510
+ outputPath,
511
+ ])
512
+
513
+ if (!result.success) throw new Error(result.failStackTrace ?? result.output)
514
+ ```
515
+
394
516
  ### Generate a thumbnail
395
517
 
396
518
  ```typescript
@@ -456,23 +578,25 @@ if (result.success) {
456
578
  }
457
579
  ```
458
580
 
459
- ## Native dependencies and licensing
581
+ ## Licensing
582
+
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:
460
584
 
461
- The JavaScript, TypeScript, Swift, Kotlin, and generated Nitro bridge code in this repository are Apache-2.0 licensed. Native FFmpeg binaries are supplied by separate compatibility packages:
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) |
462
589
 
463
- | Platform | Native dependency | Version |
464
- | -------- | --------------------------------------------------- | ------- |
465
- | iOS | `ffmpeg-kit-ios-https-alt` | 6.0 |
466
- | Android | `io.github.jamaismagic.ffmpeg:ffmpeg-kit-main-16kb` | 6.1.4 |
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.
467
591
 
468
- The bundled Android 6.1.4 artifact is GPL-enabled and includes x264 and x265. Distributing an Android application with this dependency can trigger GPLv3 source, license, and redistribution obligations. Its published Maven metadata does not fully communicate that posture, so assess the binaries and their notices rather than relying only on the POM license field.
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`.
469
593
 
470
- FFmpeg's effective license depends on the enabled libraries, codecs, and build configuration. Before distributing an application:
594
+ Before distributing an application:
471
595
 
472
596
  1. Review the license and notices shipped by each native dependency.
473
- 2. Identify the codecs and linked libraries used by your product.
597
+ 2. Identify the codecs and linked libraries your product actually uses.
474
598
  3. Follow the applicable LGPL, GPL, attribution, relinking, and source-offer requirements.
475
- 4. Treat the current Android build as GPL-enabled, and reassess licensing again if you replace either native dependency.
599
+ 4. Reassess licensing whenever you replace a native dependency.
476
600
 
477
601
  See [FFmpeg legal guidance](https://ffmpeg.org/legal.html). This section is an engineering reminder, not legal advice.
478
602
 
@@ -496,7 +620,7 @@ Rebuild the native app after installing both `munim-ffmpeg` and `react-native-ni
496
620
 
497
621
  ### A codec or filter is missing
498
622
 
499
- Native FFmpeg variants do not bundle every codec, filter, or third-party library. Check `getFFmpegVersion()` and the session output, then choose a bundled codec or replace the native dependency with a build whose licensing and features fit your application.
623
+ Native FFmpeg variants do not bundle every codec, filter, or third-party library, and the iOS and Android builds are not identical. Call `listEncoders()` or `listDecoders()` to see what the running build actually has, and prefer `pickEncoder()` over a hard-coded name. `libx264` in particular exists only on Android โ€” see [Bundled FFmpeg builds](#bundled-ffmpeg-builds).
500
624
 
501
625
  ### The Promise resolved but the command failed
502
626
 
@@ -504,24 +628,46 @@ Inspect `result.success`, `result.cancelled`, `result.returnCode`, `result.outpu
504
628
 
505
629
  ### iOS pod or build errors
506
630
 
507
- 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 used by Nitro's Swift/C++ bridge.
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
+ ```
508
650
 
509
651
  ### Android build errors
510
652
 
511
653
  Use Android API 24 or newer, JDK 17, and the React Native New Architecture. Clear stale Gradle build output after changing native dependency versions.
512
654
 
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.
656
+
513
657
  ## Development
514
658
 
515
659
  ```bash
516
660
  npm install
517
- npm run codegen
518
- npm run typecheck
519
- npm run typecheck:example
520
- npm run build
521
- npm run pack:dry-run
661
+ npm run check # codegen, typecheck, example typecheck, build, pack dry-run
522
662
  ```
523
663
 
524
- Run the Expo example with:
664
+ Individual steps are available as `npm run codegen`, `typecheck`, `typecheck:example`, and `build`.
665
+
666
+ Nitrogen output under `nitrogen/generated` is committed. Change the `.nitro.ts` specification and rerun `npm run codegen` instead of editing generated files directly.
667
+
668
+ ### Example app
669
+
670
+ `example/` is an Expo app that runs a 24-check device suite: H.264 and HEVC encoding, VP9/Opus in WebM, MP3, AAC, scaling and multi-step filter graphs, muxing, demuxing, trimming, concatenation, thumbnails, audio resampling, awkward file paths, concurrent sessions, single and global cancellation, protocol support, and both failure paths. Fixtures are generated in JavaScript, so the suite needs no network or bundled media. Results are rendered on screen, written to `munim-ffmpeg-suite.json` in the app's document directory, and logged as `MUNIM_FFMPEG_SUITE_RESULT`.
525
671
 
526
672
  ```bash
527
673
  npm run example:ios
@@ -529,15 +675,18 @@ npm run example:ios
529
675
  npm run example:android
530
676
  ```
531
677
 
532
- Releases are validated and published manually; this repository does not use GitHub Actions:
678
+ FFmpeg encoding is slow in a simulator or emulator; run the suite on a physical device.
679
+
680
+ ### Releasing
681
+
682
+ Releases run locally from a clean `main`; this repository does not use GitHub Actions.
533
683
 
534
684
  ```bash
535
685
  npm run check
536
- cd packages/munim-ffmpeg
537
- npm publish --access public
686
+ npm run release:local
538
687
  ```
539
688
 
540
- Nitrogen output under `packages/munim-ffmpeg/nitrogen/generated` is committed. Change the `.nitro.ts` specification and rerun codegen instead of editing generated files directly.
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.
541
690
 
542
691
  ## ๐Ÿ‘ Contributing
543
692
 
@@ -140,5 +140,8 @@ dependencies {
140
140
  implementation project(":react-native-nitro-modules")
141
141
 
142
142
  // Maintained FFmpegKit-compatible Android artifact with 16 KB page support.
143
- implementation "io.github.jamaismagic.ffmpeg:ffmpeg-kit-main-16kb:6.1.4"
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"
144
147
  }
package/app.plugin.js CHANGED
@@ -1,8 +1,28 @@
1
- const { withGradleProperties } = require('expo/config-plugins')
1
+ const fs = require('node:fs')
2
+ const path = require('node:path')
3
+ const {
4
+ withDangerousMod,
5
+ withGradleProperties,
6
+ } = require('expo/config-plugins')
2
7
 
3
8
  const PICK_FIRSTS_PROPERTY = 'android.packagingOptions.pickFirsts'
4
9
  const CXX_SHARED_LIBRARY = '**/libc++_shared.so'
5
10
 
11
+ const PODFILE_MARKER = '# munim-ffmpeg: allow arm64 iOS Simulator builds'
12
+ const PODFILE_SNIPPET = ` ${PODFILE_MARKER}
13
+ # The FFmpegKit pod excludes arm64 from Simulator builds, which breaks Apple
14
+ # Silicon Macs even though its xcframework ships an arm64 Simulator slice.
15
+ installer.pods_project.build_configurations.each do |config|
16
+ config.build_settings.delete('EXCLUDED_ARCHS[sdk=iphonesimulator*]')
17
+ end
18
+ installer.aggregate_targets.each do |aggregate_target|
19
+ aggregate_target.xcconfigs.each do |config_name, xcconfig|
20
+ xcconfig.attributes.delete('EXCLUDED_ARCHS[sdk=iphonesimulator*]')
21
+ xcconfig.save_as(Pathname.new(aggregate_target.xcconfig_path(config_name)))
22
+ end
23
+ end
24
+ `
25
+
6
26
  function withAndroidPackaging(config) {
7
27
  return withGradleProperties(config, (gradleConfig) => {
8
28
  const existing = gradleConfig.modResults.find(
@@ -30,8 +50,44 @@ function withAndroidPackaging(config) {
30
50
  })
31
51
  }
32
52
 
53
+ function withSimulatorArchitectures(config) {
54
+ return withDangerousMod(config, [
55
+ 'ios',
56
+ (modConfig) => {
57
+ const podfilePath = path.join(
58
+ modConfig.modRequest.platformProjectRoot,
59
+ 'Podfile'
60
+ )
61
+
62
+ if (!fs.existsSync(podfilePath)) return modConfig
63
+
64
+ const contents = fs.readFileSync(podfilePath, 'utf8')
65
+ if (contents.includes(PODFILE_MARKER)) return modConfig
66
+
67
+ const postInstall = /^([ \t]*)post_install do \|(\w+)\|[ \t]*$/m
68
+ const match = contents.match(postInstall)
69
+
70
+ if (!match) {
71
+ console.warn(
72
+ 'munim-ffmpeg: no post_install block found in the Podfile; arm64 Simulator builds may fail.'
73
+ )
74
+ return modConfig
75
+ }
76
+
77
+ const snippet = PODFILE_SNIPPET.replace(/\binstaller\b/g, match[2])
78
+ const patched = contents.replace(
79
+ postInstall,
80
+ (line) => `${line}\n${snippet}`
81
+ )
82
+
83
+ fs.writeFileSync(podfilePath, patched)
84
+ return modConfig
85
+ },
86
+ ])
87
+ }
88
+
33
89
  module.exports = function withMunimFfmpeg(config) {
34
- return withAndroidPackaging(config)
90
+ return withSimulatorArchitectures(withAndroidPackaging(config))
35
91
  }
36
92
 
37
93
  module.exports.default = module.exports
@@ -128,6 +128,18 @@ final class HybridMunimFfmpeg: HybridMunimFfmpegSpec {
128
128
  FFmpegKit.cancel()
129
129
  }
130
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
+ }
141
+ }
142
+
131
143
  private static func result(from session: Session) -> FFmpegSessionResult {
132
144
  let returnCode = session.getReturnCode()
133
145
  return FFmpegSessionResult(
@@ -135,7 +147,7 @@ final class HybridMunimFfmpeg: HybridMunimFfmpegSpec {
135
147
  returnCode: Double(returnCode?.getValue() ?? -1),
136
148
  success: ReturnCode.isSuccess(returnCode),
137
149
  cancelled: ReturnCode.isCancel(returnCode),
138
- state: String(describing: session.getState()).lowercased(),
150
+ state: Self.stateName(session.getState()),
139
151
  durationMs: Double(session.getDuration()),
140
152
  output: session.getOutput() ?? "",
141
153
  failStackTrace: session.getFailStackTrace()
package/lib/index.d.ts CHANGED
@@ -1,10 +1,33 @@
1
1
  import type { FFmpegLogCallback, FFmpegSessionResult, FFmpegSessionCreatedCallback, FFmpegStatisticsCallback, MunimFfmpeg as MunimFfmpegSpec } from './specs/MunimFfmpeg.nitro';
2
2
  declare const MunimFfmpeg: MunimFfmpegSpec;
3
3
  export type { FFmpegLogCallback, FFmpegSessionResult, FFmpegSessionCreatedCallback, FFmpegStatisticsCallback, MunimFfmpegSpec, };
4
+ /**
5
+ * Converts a `file://` URI into the plain path FFmpeg expects.
6
+ *
7
+ * `expo-file-system` and `react-native-fs` hand back percent-encoded URIs, but
8
+ * FFmpeg's file protocol treats what follows `file://` literally: a path with a
9
+ * space silently becomes a file named `my%20clip.mp4`. Anything that is not a
10
+ * `file://` URI is returned untouched, so pipes, `content://`, and remote URLs
11
+ * still work.
12
+ */
13
+ export declare function normalizePath(value: string): string;
4
14
  export declare function execute(arguments_: string[], onLog?: FFmpegLogCallback, onStatistics?: FFmpegStatisticsCallback, onSessionCreated?: FFmpegSessionCreatedCallback): Promise<FFmpegSessionResult>;
5
15
  export declare function probe(arguments_: string[], onLog?: FFmpegLogCallback, onSessionCreated?: FFmpegSessionCreatedCallback): Promise<FFmpegSessionResult>;
6
16
  export declare function getMediaInformation(path: string): Promise<unknown>;
7
17
  export declare function cancel(sessionId?: number): void;
8
18
  export declare function cancelAll(): void;
9
19
  export declare function getFFmpegVersion(): string;
20
+ /** Encoder names the bundled FFmpeg build can write, e.g. `libx264`. */
21
+ export declare function listEncoders(): Promise<string[]>;
22
+ /** Decoder names the bundled FFmpeg build can read, e.g. `h264`. */
23
+ export declare function listDecoders(): Promise<string[]>;
24
+ /**
25
+ * Returns the first available encoder from `candidates`, so one command can
26
+ * run on both platforms:
27
+ *
28
+ * ```ts
29
+ * const encoder = await pickEncoder(['libx264', 'h264_videotoolbox'])
30
+ * ```
31
+ */
32
+ export declare function pickEncoder(candidates: string[]): Promise<string | undefined>;
10
33
  export default MunimFfmpeg;
package/lib/index.js CHANGED
@@ -1,13 +1,34 @@
1
1
  import { NitroModules } from 'react-native-nitro-modules';
2
2
  const MunimFfmpeg = NitroModules.createHybridObject('MunimFfmpeg');
3
+ const FILE_URI_SCHEME = /^file:\/\//;
4
+ /**
5
+ * Converts a `file://` URI into the plain path FFmpeg expects.
6
+ *
7
+ * `expo-file-system` and `react-native-fs` hand back percent-encoded URIs, but
8
+ * FFmpeg's file protocol treats what follows `file://` literally: a path with a
9
+ * space silently becomes a file named `my%20clip.mp4`. Anything that is not a
10
+ * `file://` URI is returned untouched, so pipes, `content://`, and remote URLs
11
+ * still work.
12
+ */
13
+ export function normalizePath(value) {
14
+ if (!FILE_URI_SCHEME.test(value))
15
+ return value;
16
+ const path = value.replace(FILE_URI_SCHEME, '');
17
+ try {
18
+ return decodeURIComponent(path);
19
+ }
20
+ catch {
21
+ return path;
22
+ }
23
+ }
3
24
  export function execute(arguments_, onLog, onStatistics, onSessionCreated) {
4
- return MunimFfmpeg.execute(arguments_, onLog, onStatistics, onSessionCreated);
25
+ return MunimFfmpeg.execute(arguments_.map(normalizePath), onLog, onStatistics, onSessionCreated);
5
26
  }
6
27
  export function probe(arguments_, onLog, onSessionCreated) {
7
- return MunimFfmpeg.probe(arguments_, onLog, onSessionCreated);
28
+ return MunimFfmpeg.probe(arguments_.map(normalizePath), onLog, onSessionCreated);
8
29
  }
9
30
  export function getMediaInformation(path) {
10
- return MunimFfmpeg.getMediaInformation(path).then((value) => JSON.parse(value));
31
+ return MunimFfmpeg.getMediaInformation(normalizePath(path)).then((value) => JSON.parse(value));
11
32
  }
12
33
  export function cancel(sessionId) {
13
34
  MunimFfmpeg.cancel(sessionId);
@@ -18,4 +39,53 @@ export function cancelAll() {
18
39
  export function getFFmpegVersion() {
19
40
  return MunimFfmpeg.ffmpegVersion;
20
41
  }
42
+ // The bundled FFmpeg builds differ per platform: Android ships libx264/libx265,
43
+ // iOS ships the VideoToolbox hardware encoders instead. Asking the binary what
44
+ // it supports is more reliable than hard-coding a per-platform table.
45
+ const codecCache = new Map();
46
+ function listCodecs(flag) {
47
+ const cached = codecCache.get(flag);
48
+ if (cached)
49
+ return cached;
50
+ const request = execute(['-hide_banner', flag])
51
+ .then((result) => {
52
+ if (!result.success) {
53
+ throw new Error(result.failStackTrace ?? result.output);
54
+ }
55
+ // Each entry is printed as `<capability flags> <name> <description>`
56
+ // below a line of dashes.
57
+ const body = result.output.split(/^\s*-+\s*$/m).pop() ?? '';
58
+ return body
59
+ .split('\n')
60
+ .map((line) => line.trim().split(/\s+/))
61
+ .filter((columns) => columns.length >= 2 && /^[A-Z.]{6}$/.test(columns[0]))
62
+ .map((columns) => columns[1]);
63
+ })
64
+ .catch((error) => {
65
+ codecCache.delete(flag);
66
+ throw error;
67
+ });
68
+ codecCache.set(flag, request);
69
+ return request;
70
+ }
71
+ /** Encoder names the bundled FFmpeg build can write, e.g. `libx264`. */
72
+ export function listEncoders() {
73
+ return listCodecs('-encoders');
74
+ }
75
+ /** Decoder names the bundled FFmpeg build can read, e.g. `h264`. */
76
+ export function listDecoders() {
77
+ return listCodecs('-decoders');
78
+ }
79
+ /**
80
+ * Returns the first available encoder from `candidates`, so one command can
81
+ * run on both platforms:
82
+ *
83
+ * ```ts
84
+ * const encoder = await pickEncoder(['libx264', 'h264_videotoolbox'])
85
+ * ```
86
+ */
87
+ export async function pickEncoder(candidates) {
88
+ const encoders = new Set(await listEncoders());
89
+ return candidates.find((candidate) => encoders.has(candidate));
90
+ }
21
91
  export default MunimFfmpeg;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "munim-ffmpeg",
3
- "version": "0.1.1",
3
+ "version": "0.2.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",
@@ -23,23 +23,28 @@
23
23
  "ios/**/*.mm",
24
24
  "ios/**/*.cpp",
25
25
  "ios/**/*.swift",
26
- "scripts",
27
26
  "app.plugin.js",
28
27
  "nitro.json",
29
28
  "*.podspec",
30
29
  "README.md",
31
- "LICENSE"
30
+ "LICENSE",
31
+ "scripts/patch-ffmpegkit-level.rb"
32
32
  ],
33
33
  "scripts": {
34
34
  "typecheck": "tsc --noEmit",
35
- "clean": "rm -rf android/build node_modules/**/android/build lib",
36
- "lint": "eslint \"**/*.{js,ts,tsx}\" --fix",
37
- "lint-ci": "eslint \"**/*.{js,ts,tsx}\" -f @jamesacarr/github-actions --max-warnings 0",
35
+ "typecheck:example": "tsc --noEmit -p example/tsconfig.json",
36
+ "clean": "rm -rf lib android/build example/ios example/android",
37
+ "lint": "eslint .",
38
+ "codegen": "nitrogen --logLevel=\"debug\"",
39
+ "specs": "npm run codegen",
38
40
  "build": "npm run typecheck && tsc",
39
41
  "prepack": "npm run build",
40
- "typescript": "tsc",
41
- "codegen": "nitrogen --logLevel=\"debug\"",
42
- "specs": "npm run codegen"
42
+ "check": "npm run codegen && npm run typecheck && npm run typecheck:example && npm run build && npm pack --dry-run",
43
+ "example:start": "npm --workspace example run start",
44
+ "example:ios": "npm --workspace example run ios",
45
+ "example:android": "npm --workspace example run android",
46
+ "release:local": "node scripts/release-local.mjs",
47
+ "format": "prettier --write \"**/*.{ts,tsx,js,cjs,mjs,json,md}\""
43
48
  },
44
49
  "keywords": [
45
50
  "react-native",
@@ -61,6 +66,9 @@
61
66
  "transcoding",
62
67
  "munim-technologies"
63
68
  ],
69
+ "workspaces": [
70
+ "example"
71
+ ],
64
72
  "repository": {
65
73
  "type": "git",
66
74
  "url": "git+https://github.com/munimtechnologies/munim-ffmpeg.git"
@@ -83,49 +91,30 @@
83
91
  ]
84
92
  },
85
93
  "devDependencies": {
86
- "@react-native/eslint-config": "0.85.3",
94
+ "@eslint/eslintrc": "^3.3.6",
95
+ "@eslint/js": "^9.39.0",
96
+ "@semantic-release/changelog": "^6.0.3",
97
+ "@semantic-release/git": "^10.0.1",
87
98
  "@types/react": "^19.2.15",
99
+ "conventional-changelog-conventionalcommits": "^9.3.1",
88
100
  "eslint": "^9.39.4",
89
101
  "eslint-config-prettier": "^10.1.8",
90
102
  "eslint-plugin-prettier": "^5.5.5",
103
+ "globals": "^17.11.0",
91
104
  "nitrogen": "0.36.5",
92
105
  "prettier": "^3.8.3",
93
106
  "react": "19.2.3",
94
107
  "react-native": "0.86.2",
95
108
  "react-native-nitro-modules": "^0.36.5",
96
- "typescript": "^6.0.3"
109
+ "semantic-release": "^25.0.9",
110
+ "typescript": "^6.0.3",
111
+ "typescript-eslint": "^8.46.0"
97
112
  },
98
113
  "peerDependencies": {
99
114
  "react": "*",
100
115
  "react-native": "*",
101
116
  "react-native-nitro-modules": ">=0.36.5 <1"
102
117
  },
103
- "eslintConfig": {
104
- "root": true,
105
- "extends": [
106
- "@react-native",
107
- "prettier"
108
- ],
109
- "plugins": [
110
- "prettier"
111
- ],
112
- "rules": {
113
- "prettier/prettier": [
114
- "warn",
115
- {
116
- "quoteProps": "consistent",
117
- "singleQuote": true,
118
- "tabWidth": 2,
119
- "trailingComma": "es5",
120
- "useTabs": false
121
- }
122
- ]
123
- }
124
- },
125
- "eslintIgnore": [
126
- "node_modules/",
127
- "lib/"
128
- ],
129
118
  "prettier": {
130
119
  "quoteProps": "consistent",
131
120
  "singleQuote": true,
@@ -3,24 +3,22 @@
3
3
  require 'fileutils'
4
4
  require 'tempfile'
5
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.
6
15
  pods_root = ENV.fetch('PODS_ROOT')
7
16
  build_products = ENV.fetch('PODS_CONFIGURATION_BUILD_DIR')
8
- source_root = File.join(pods_root, 'ffmpeg-kit-ios-https-alt', 'ffmpegkit.xcframework')
9
-
10
- # Patch only the two vendored slices and CocoaPods' selected build product. Nitro's
11
- # Swift/C++ bridge imports the selected header after this before-compile phase.
12
- headers = [
13
- File.join(source_root, 'ios-arm64', 'ffmpegkit.framework', 'Headers', 'Level.h'),
14
- File.join(source_root, 'ios-arm64_x86_64-simulator', 'ffmpegkit.framework', 'Headers', 'Level.h'),
15
- File.join(
16
- build_products,
17
- 'XCFrameworkIntermediates',
18
- 'ffmpeg-kit-ios-https-alt',
19
- 'ffmpegkit.framework',
20
- 'Headers',
21
- 'Level.h'
22
- ),
23
- ].select { |path| File.file?(path) }
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) }
24
22
 
25
23
  abort('munim-ffmpeg: could not find the expected FFmpegKit Level.h') if headers.empty?
26
24
 
package/src/index.ts CHANGED
@@ -18,13 +18,40 @@ export type {
18
18
  MunimFfmpegSpec,
19
19
  }
20
20
 
21
+ const FILE_URI_SCHEME = /^file:\/\//
22
+
23
+ /**
24
+ * Converts a `file://` URI into the plain path FFmpeg expects.
25
+ *
26
+ * `expo-file-system` and `react-native-fs` hand back percent-encoded URIs, but
27
+ * FFmpeg's file protocol treats what follows `file://` literally: a path with a
28
+ * space silently becomes a file named `my%20clip.mp4`. Anything that is not a
29
+ * `file://` URI is returned untouched, so pipes, `content://`, and remote URLs
30
+ * still work.
31
+ */
32
+ export function normalizePath(value: string): string {
33
+ if (!FILE_URI_SCHEME.test(value)) return value
34
+
35
+ const path = value.replace(FILE_URI_SCHEME, '')
36
+ try {
37
+ return decodeURIComponent(path)
38
+ } catch {
39
+ return path
40
+ }
41
+ }
42
+
21
43
  export function execute(
22
44
  arguments_: string[],
23
45
  onLog?: FFmpegLogCallback,
24
46
  onStatistics?: FFmpegStatisticsCallback,
25
47
  onSessionCreated?: FFmpegSessionCreatedCallback
26
48
  ): Promise<FFmpegSessionResult> {
27
- return MunimFfmpeg.execute(arguments_, onLog, onStatistics, onSessionCreated)
49
+ return MunimFfmpeg.execute(
50
+ arguments_.map(normalizePath),
51
+ onLog,
52
+ onStatistics,
53
+ onSessionCreated
54
+ )
28
55
  }
29
56
 
30
57
  export function probe(
@@ -32,11 +59,15 @@ export function probe(
32
59
  onLog?: FFmpegLogCallback,
33
60
  onSessionCreated?: FFmpegSessionCreatedCallback
34
61
  ): Promise<FFmpegSessionResult> {
35
- return MunimFfmpeg.probe(arguments_, onLog, onSessionCreated)
62
+ return MunimFfmpeg.probe(
63
+ arguments_.map(normalizePath),
64
+ onLog,
65
+ onSessionCreated
66
+ )
36
67
  }
37
68
 
38
69
  export function getMediaInformation(path: string): Promise<unknown> {
39
- return MunimFfmpeg.getMediaInformation(path).then((value) =>
70
+ return MunimFfmpeg.getMediaInformation(normalizePath(path)).then((value) =>
40
71
  JSON.parse(value)
41
72
  )
42
73
  }
@@ -53,4 +84,64 @@ export function getFFmpegVersion(): string {
53
84
  return MunimFfmpeg.ffmpegVersion
54
85
  }
55
86
 
87
+ // The bundled FFmpeg builds differ per platform: Android ships libx264/libx265,
88
+ // iOS ships the VideoToolbox hardware encoders instead. Asking the binary what
89
+ // it supports is more reliable than hard-coding a per-platform table.
90
+ const codecCache = new Map<string, Promise<string[]>>()
91
+
92
+ function listCodecs(flag: '-encoders' | '-decoders'): Promise<string[]> {
93
+ const cached = codecCache.get(flag)
94
+ if (cached) return cached
95
+
96
+ const request = execute(['-hide_banner', flag])
97
+ .then((result) => {
98
+ if (!result.success) {
99
+ throw new Error(result.failStackTrace ?? result.output)
100
+ }
101
+
102
+ // Each entry is printed as `<capability flags> <name> <description>`
103
+ // below a line of dashes.
104
+ const body = result.output.split(/^\s*-+\s*$/m).pop() ?? ''
105
+ return body
106
+ .split('\n')
107
+ .map((line) => line.trim().split(/\s+/))
108
+ .filter(
109
+ (columns) => columns.length >= 2 && /^[A-Z.]{6}$/.test(columns[0]!)
110
+ )
111
+ .map((columns) => columns[1]!)
112
+ })
113
+ .catch((error) => {
114
+ codecCache.delete(flag)
115
+ throw error
116
+ })
117
+
118
+ codecCache.set(flag, request)
119
+ return request
120
+ }
121
+
122
+ /** Encoder names the bundled FFmpeg build can write, e.g. `libx264`. */
123
+ export function listEncoders(): Promise<string[]> {
124
+ return listCodecs('-encoders')
125
+ }
126
+
127
+ /** Decoder names the bundled FFmpeg build can read, e.g. `h264`. */
128
+ export function listDecoders(): Promise<string[]> {
129
+ return listCodecs('-decoders')
130
+ }
131
+
132
+ /**
133
+ * Returns the first available encoder from `candidates`, so one command can
134
+ * run on both platforms:
135
+ *
136
+ * ```ts
137
+ * const encoder = await pickEncoder(['libx264', 'h264_videotoolbox'])
138
+ * ```
139
+ */
140
+ export async function pickEncoder(
141
+ candidates: string[]
142
+ ): Promise<string | undefined> {
143
+ const encoders = new Set(await listEncoders())
144
+ return candidates.find((candidate) => encoders.has(candidate))
145
+ }
146
+
56
147
  export default MunimFfmpeg