munim-ffmpeg 0.4.2 โ†’ 0.5.1

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.
@@ -30,7 +30,8 @@ Pod::Spec.new do |s|
30
30
  s.pod_target_xcconfig = {
31
31
  "HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/ios\"",
32
32
  }
33
- s.frameworks = "AudioToolbox", "VideoToolbox", "CoreMedia", "AVFoundation", "CoreVideo", "Security"
33
+ # CoreText and CoreGraphics are libass's font provider on iOS.
34
+ s.frameworks = "AudioToolbox", "VideoToolbox", "CoreMedia", "AVFoundation", "CoreVideo", "Security", "CoreText", "CoreGraphics"
34
35
  s.libraries = "bz2", "z", "iconv", "c++"
35
36
 
36
37
  # Must be public so CocoaPods puts it in the module umbrella, which is how the
package/README.md CHANGED
@@ -112,7 +112,8 @@
112
112
  - ๐Ÿ“ฑ **iOS and Android:** Native implementations in Swift and Kotlin
113
113
  - ๐Ÿงฌ **Nitro Modules:** Generated high-performance native bindings
114
114
  - ๐Ÿš€ **Expo compatible:** Autolinking, config plugin, and an Expo development example
115
- - ๐Ÿงช **Capability discovery:** Ask the bundled build which encoders and decoders it actually has
115
+ - ๐Ÿงช **Capability discovery:** Ask the bundled build which encoders, decoders, muxers, demuxers, filters, and protocols it actually has
116
+ - ๐Ÿ’ฌ **Subtitle burn-in:** libass renders ASS/SSA and SRT subtitles โ€” styling, positioning, outlines, shadows, and proper Arabic/Urdu shaping via HarfBuzz and FriBidi
116
117
  - ๐ŸŽฏ **TypeScript:** Complete public callback and result types
117
118
  - ๐Ÿ—‚๏ธ **16 KB Android pages:** Built with the alignment Google Play requires
118
119
 
@@ -129,9 +130,10 @@
129
130
  | Cancel one FFmpeg session | โœ… | โœ… | Pass the positive safe-integer ID received by `execute`'s `onSessionCreated`. The native dependency does not expose FFprobe cancellation. |
130
131
  | Cancel all FFmpeg sessions | โœ… | โœ… | Use `cancelAll()` or call `cancel()` without an ID. |
131
132
  | Expo Go | โŒ | โŒ | A native development build is required. |
132
- | Capability discovery | โœ… | โœ… | `listEncoders()`, `listDecoders()`, and `pickEncoder()` report what the bundled build supports. |
133
- | H.264 encoding | VideoToolbox | libx264 | The builds differ; use `pickEncoder(['libx264', 'h264_videotoolbox'])` instead of hard-coding an encoder. |
134
- | Remote HTTP(S) inputs | โœ… | โœ… | Both builds link GnuTLS. Remote server behaviour still varies; prefer local files for predictable app workflows. |
133
+ | Capability discovery | โœ… | โœ… | `listEncoders()`, `listDecoders()`, `listMuxers()`, `listDemuxers()`, `listFilters()`, `listProtocols()`, and `pickEncoder()` report what the bundled build supports. |
134
+ | Subtitle burn-in | โœ… | โœ… | libass with system fonts: Core Text on iOS, fontconfig over `/system/fonts` on Android. |
135
+ | H.264 encoding | VideoToolbox | MediaCodec | Hardware on both, `libopenh264` as the software fallback; use `pickEncoder(['h264_videotoolbox', 'h264_mediacodec', 'libopenh264'])` instead of hard-coding an encoder. |
136
+ | Remote HTTP(S) inputs | โœ… | โœ… | iOS links SecureTransport, Android links mbedTLS. Remote server behaviour still varies; prefer local files for predictable app workflows. |
135
137
 
136
138
  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.
137
139
 
@@ -177,13 +179,13 @@ Both platforms run **FFmpeg 9.0.1**, built from [ffmpeg.org](https://www.ffmpeg.
177
179
  | TLS | SecureTransport | mbedTLS |
178
180
  | Minimum | iOS 15.1 | API 24, 16 KB pages |
179
181
 
180
- Linked libraries, identical on both: **LAME** (MP3), **Opus**, **libvpx** (VP8/VP9), **dav1d** (AV1 decoding), **openh264** (software H.264), plus everything FFmpeg builds natively.
182
+ Linked libraries, identical on both: **LAME** (MP3), **Opus**, **libvpx** (VP8/VP9), **dav1d** (AV1 decoding), **openh264** (software H.264), **libass** with **FreeType**, **HarfBuzz**, and **FriBidi** (subtitle rendering and text shaping), plus everything FFmpeg builds natively. Android additionally links **fontconfig** and **expat** so libass can discover the system fonts; iOS uses Core Text for the same job.
181
183
 
182
184
  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.
183
185
 
184
186
  ### Encoders
185
187
 
186
- 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`.
188
+ Verified by running the example's device suite: iOS reports 187 encoders, Android 185. Everything FFmpeg builds natively (`aac`, `alac`, `flac`, `mpeg4`, `mjpeg`, `png`, `gif`, `pcm_*`, โ€ฆ) is on both, as are `libmp3lame`, `libopus`, `libvpx`, and `libvpx-vp9`.
187
189
 
188
190
  H.264 and HEVC come from the platform's hardware encoder, which is faster and uses less power than a software encoder. `libopenh264` is there as a software H.264 fallback for anywhere hardware encoding is unavailable โ€” an emulator, for instance:
189
191
 
@@ -463,6 +465,31 @@ Returns the decoder names the bundled FFmpeg build can read.
463
465
  function listDecoders(): Promise<string[]>
464
466
  ```
465
467
 
468
+ ### `listMuxers()` / `listDemuxers()`
469
+
470
+ Return the container formats the bundled FFmpeg build can write and read, e.g. `mp4`, `matroska`, `webm`. Cached after the first call.
471
+
472
+ ```typescript
473
+ function listMuxers(): Promise<string[]>
474
+ function listDemuxers(): Promise<string[]>
475
+ ```
476
+
477
+ ### `listFilters()`
478
+
479
+ Returns the filter names the bundled FFmpeg build provides, e.g. `subtitles`, `ass`, `drawtext`, `scale`.
480
+
481
+ ```typescript
482
+ function listFilters(): Promise<string[]>
483
+ ```
484
+
485
+ ### `listProtocols()`
486
+
487
+ Returns the protocol names the bundled FFmpeg build provides, e.g. `file`, `https`, `concat`.
488
+
489
+ ```typescript
490
+ function listProtocols(): Promise<string[]>
491
+ ```
492
+
466
493
  ### `pickEncoder(candidates)`
467
494
 
468
495
  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.
@@ -540,11 +567,12 @@ if (!result.success) {
540
567
  ```typescript
541
568
  import { execute, pickEncoder } from 'munim-ffmpeg'
542
569
 
543
- const encoder = await pickEncoder(['libx264', 'h264_videotoolbox'])
570
+ const encoder = await pickEncoder(['h264_videotoolbox', 'h264_mediacodec', 'libopenh264'])
544
571
  if (!encoder) throw new Error('No H.264 encoder available in this build')
545
572
 
546
- // -preset is an x264 option; VideoToolbox rejects it.
547
- const quality = encoder === 'libx264' ? ['-preset', 'veryfast', '-crf', '23'] : ['-b:v', '2M']
573
+ // MediaCodec wants NV12 input; the others take planar YUV.
574
+ const pixelFormat = encoder === 'h264_mediacodec' ? 'nv12' : 'yuv420p'
575
+ const quality = ['-b:v', '2M', '-pix_fmt', pixelFormat]
548
576
 
549
577
  const result = await execute([
550
578
  '-y',
@@ -580,6 +608,58 @@ const result = await execute([
580
608
  ])
581
609
  ```
582
610
 
611
+ ### Burn subtitles into a video
612
+
613
+ The bundled builds include libass with FreeType, HarfBuzz, and FriBidi, so ASS/SSA styling and complex scripts (Arabic, Urdu, and other RTL or shaped text) render correctly. System fonts are found automatically โ€” through Core Text on iOS and through fontconfig scanning `/system/fonts` on Android.
614
+
615
+ ```typescript
616
+ import { execute, normalizePath } from 'munim-ffmpeg'
617
+
618
+ // ASS/SSA keeps its embedded styling: fonts, colours, outlines,
619
+ // shadows, positioning, karaoke โ€” everything the format supports.
620
+ await execute([
621
+ '-y',
622
+ '-i', inputPath,
623
+ '-vf', `ass=filename=${normalizePath(subtitlePath)}`,
624
+ '-c:a', 'copy',
625
+ outputPath,
626
+ ])
627
+
628
+ // SRT can be styled at burn time with force_style.
629
+ await execute([
630
+ '-y',
631
+ '-i', inputPath,
632
+ '-vf', `subtitles=filename=${normalizePath(srtPath)}:force_style='Fontsize=28,PrimaryColour=&H00FFFF00,Outline=2'`,
633
+ '-c:a', 'copy',
634
+ outputPath,
635
+ ])
636
+ ```
637
+
638
+ To ship your own fonts instead of relying on the device's, put them in a directory and add `:fontsdir=/path/to/fonts` to the filter. Note that filter arguments are colon-separated, so a path containing `:` must be escaped โ€” app sandbox paths on both platforms are safe as-is.
639
+
640
+ ### Work with MKV and multiple tracks
641
+
642
+ Matroska muxing and demuxing is compiled in, along with FFmpeg's standard `-map` stream selection and `-c copy` remuxing:
643
+
644
+ ```typescript
645
+ import { execute } from 'munim-ffmpeg'
646
+
647
+ // Bundle one video, two audio languages, and a subtitle track into MKV.
648
+ await execute([
649
+ '-y',
650
+ '-i', videoPath, '-i', urduAudioPath, '-i', subtitlePath,
651
+ '-map', '0:v:0', '-map', '0:a:0', '-map', '1:a:0', '-map', '2:s:0',
652
+ '-c:v', 'copy', '-c:a', 'aac', '-c:s', 'srt',
653
+ '-metadata:s:a:1', 'language=urd',
654
+ outputMkvPath,
655
+ ])
656
+
657
+ // Extract the second audio track without re-encoding.
658
+ await execute([
659
+ '-y', '-i', outputMkvPath, '-map', '0:a:1', '-c', 'copy', trackPath,
660
+ ])
661
+ ```
662
+
583
663
  ### Cancel a long-running command
584
664
 
585
665
  ```typescript
@@ -632,7 +712,7 @@ if (result.success) {
632
712
 
633
713
  The JavaScript, TypeScript, Swift, Kotlin, C core, and generated Nitro bridge in this repository are Apache-2.0.
634
714
 
635
- 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. The external libraries it links are LAME (LGPL), Opus (BSD), libvpx (BSD), dav1d (BSD), openh264 (BSD 2-clause), and mbedTLS (Apache-2.0) on Android.
715
+ 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. The external libraries it links are LAME (LGPL), Opus (BSD), libvpx (BSD), dav1d (BSD), openh264 (BSD 2-clause), libass (ISC), FreeType (FTL, BSD-style with credit), HarfBuzz (MIT-style), FriBidi (LGPL), and, on Android only, mbedTLS (Apache-2.0), fontconfig (MIT-style), and expat (MIT). None of them change the LGPL story.
636
716
 
637
717
  > **A note on H.264 patents.** Hardware encoders are covered by the licences device manufacturers already pay for. Software H.264 encoding through `libopenh264` is not: Cisco's royalty coverage applies to *their* prebuilt binary, and this package builds openh264 from source. If you ship software H.264 encoding at scale, check where you stand with AVC licensing. Hardware encoders avoid the question entirely, which is why `pickEncoder` should list them first.
638
718
 
@@ -662,7 +742,7 @@ Rebuild the native app after installing both `munim-ffmpeg` and `react-native-ni
662
742
 
663
743
  ### A codec or filter is missing
664
744
 
665
- 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).
745
+ Native FFmpeg variants do not bundle every codec, filter, or third-party library, and the iOS and Android builds are not identical. Call `listEncoders()`, `listDecoders()`, `listMuxers()`, `listDemuxers()`, `listFilters()`, or `listProtocols()` to see what the running build actually has, and prefer `pickEncoder()` over a hard-coded name. There is no `libx264` in these LGPL builds โ€” H.264 comes from the platform's hardware encoder or `libopenh264` โ€” see [Bundled FFmpeg builds](#bundled-ffmpeg-builds).
666
746
 
667
747
  ### The Promise resolved but the command failed
668
748
 
@@ -1,7 +1,58 @@
1
1
  package com.margelo.nitro.munimffmpeg
2
2
 
3
+ import android.system.Os
3
4
  import androidx.annotation.Keep
4
5
  import com.facebook.proguard.annotations.DoNotStrip
6
+ import java.io.File
7
+
8
+ /**
9
+ * Per-run callback target. The JNI bridge holds a global reference to this
10
+ * object for the duration of its execution and the native core only routes
11
+ * callbacks to whichever session actually holds the execution lock, so
12
+ * concurrently submitted sessions never see each other's logs.
13
+ */
14
+ @Keep
15
+ @DoNotStrip
16
+ class FFmpegSession(
17
+ private val logSink: ((String) -> Unit)?,
18
+ private val statisticsSink: ((
19
+ timeMs: Double,
20
+ sizeBytes: Double,
21
+ bitrateKbits: Double,
22
+ speed: Double,
23
+ videoFrameNumber: Double,
24
+ fps: Double,
25
+ quality: Double,
26
+ ) -> Unit)?,
27
+ ) {
28
+ private val buffer = StringBuilder()
29
+
30
+ val output: String
31
+ get() = synchronized(buffer) { buffer.toString() }
32
+
33
+ // FFmpeg logs from several of its own threads at once, so the buffer needs
34
+ // the lock even though each session belongs to a single execution.
35
+ @Keep
36
+ @DoNotStrip
37
+ fun onLog(message: String) {
38
+ synchronized(buffer) { buffer.append(message) }
39
+ logSink?.invoke(message)
40
+ }
41
+
42
+ @Keep
43
+ @DoNotStrip
44
+ fun onStatistics(
45
+ timeMs: Double,
46
+ sizeBytes: Double,
47
+ bitrateKbits: Double,
48
+ speed: Double,
49
+ videoFrameNumber: Double,
50
+ fps: Double,
51
+ quality: Double,
52
+ ) {
53
+ statisticsSink?.invoke(timeMs, sizeBytes, bitrateKbits, speed, videoFrameNumber, fps, quality)
54
+ }
55
+ }
5
56
 
6
57
  /**
7
58
  * Thin wrapper over FFmpeg 9's own command-line tools, compiled to run inside
@@ -15,6 +66,7 @@ import com.facebook.proguard.annotations.DoNotStrip
15
66
  @DoNotStrip
16
67
  object FFmpegNative {
17
68
  init {
69
+ configureFontconfig()
18
70
  System.loadLibrary("munimffmpeg9")
19
71
  }
20
72
 
@@ -23,64 +75,50 @@ object FFmpegNative {
23
75
 
24
76
  external fun nativeVersion(): String
25
77
 
26
- external fun nativeExecute(arguments: Array<String>, stdoutPath: String): Int
78
+ external fun nativeExecute(
79
+ arguments: Array<String>,
80
+ stdoutPath: String,
81
+ session: FFmpegSession,
82
+ ): Int
27
83
 
28
- external fun nativeExecuteProbe(arguments: Array<String>, outputPath: String): Int
84
+ external fun nativeExecuteProbe(
85
+ arguments: Array<String>,
86
+ outputPath: String,
87
+ session: FFmpegSession,
88
+ ): Int
29
89
 
30
90
  external fun nativeCancel()
31
91
 
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
- )
92
+ /**
93
+ * libass discovers fonts through fontconfig, and Android has no fonts.conf,
94
+ * so one pointing at the system font directories is written to the app's
95
+ * cache and exported before FFmpeg first runs. Without it, `subtitles=` and
96
+ * `drawtext` render nothing. Apps that manage their own fontconfig setup can
97
+ * set FONTCONFIG_FILE first; it is never overwritten.
98
+ */
99
+ private fun configureFontconfig() {
100
+ runCatching {
101
+ if (!System.getenv("FONTCONFIG_FILE").isNullOrEmpty()) return
41
102
 
42
- @Volatile
43
- private var logSink: ((String) -> Unit)? = null
103
+ val root = File(System.getProperty("java.io.tmpdir"), "munim-ffmpeg-fontconfig")
104
+ val cache = File(root, "cache")
105
+ cache.mkdirs()
44
106
 
45
- @Volatile
46
- private var statisticsSink: ((Statistics) -> Unit)? = null
107
+ val configuration = File(root, "fonts.conf")
108
+ configuration.writeText(
109
+ """
110
+ <?xml version="1.0"?>
111
+ <!DOCTYPE fontconfig SYSTEM "fonts.dtd">
112
+ <fontconfig>
113
+ <dir>/system/fonts</dir>
114
+ <dir>/system/font</dir>
115
+ <dir>/product/fonts</dir>
116
+ <cachedir>${cache.absolutePath}</cachedir>
117
+ </fontconfig>
118
+ """.trimIndent()
119
+ )
47
120
 
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
121
+ Os.setenv("FONTCONFIG_FILE", configuration.absolutePath, true)
60
122
  }
61
123
  }
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
124
  }
@@ -53,37 +53,21 @@ class HybridMunimFfmpeg : HybridMunimFfmpegSpec() {
53
53
  onSessionCreated?.invoke(sessionId)
54
54
 
55
55
  return Promise.async {
56
- val output = StringBuilder()
57
56
  val startedAt = System.currentTimeMillis()
58
57
  // ffmpeg prints reports such as -encoders and -protocols to stdout rather
59
58
  // than through its logger, so it is captured to a file and appended.
60
59
  val stdout = File.createTempFile("munim-ffmpeg", ".txt")
61
60
 
62
- val returnCode = FFmpegNative.withCallbacks(
63
- onLog = { message ->
64
- output.append(message)
65
- onLog?.invoke(message)
66
- },
67
- onStatistics = { statistics ->
68
- onStatistics?.invoke(
69
- statistics.timeMs,
70
- statistics.sizeBytes,
71
- statistics.bitrateKbits,
72
- statistics.speed,
73
- statistics.videoFrameNumber,
74
- statistics.fps,
75
- statistics.quality,
76
- )
77
- },
78
- ) {
79
- FFmpegNative.nativeExecute(arguments_, stdout.absolutePath)
80
- }
61
+ val session = FFmpegSession(
62
+ logSink = { message -> onLog?.invoke(message) },
63
+ statisticsSink = onStatistics,
64
+ )
65
+ val returnCode = FFmpegNative.nativeExecute(arguments_, stdout.absolutePath, session)
81
66
 
82
67
  val printed = runCatching { stdout.readText() }.getOrDefault("")
83
68
  stdout.delete()
84
- output.append(printed)
85
69
 
86
- result(sessionId, returnCode, output.toString(), System.currentTimeMillis() - startedAt)
70
+ result(sessionId, returnCode, session.output + printed, System.currentTimeMillis() - startedAt)
87
71
  }
88
72
  }
89
73
 
@@ -138,19 +122,15 @@ class HybridMunimFfmpeg : HybridMunimFfmpegSpec() {
138
122
  ): Pair<Int, String> {
139
123
  val destination = File.createTempFile("munim-ffprobe", ".txt")
140
124
  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
- }
125
+ val session = FFmpegSession(
126
+ logSink = { message -> onLog?.invoke(message) },
127
+ statisticsSink = null,
128
+ )
129
+ val returnCode =
130
+ FFmpegNative.nativeExecuteProbe(arguments, destination.absolutePath, session)
151
131
 
152
132
  val report = destination.readText()
153
- return returnCode to report.ifEmpty { logs.toString() }
133
+ return returnCode to report.ifEmpty { session.output }
154
134
  } finally {
155
135
  destination.delete()
156
136
  }
@@ -1,21 +1,39 @@
1
1
  import Foundation
2
2
  import NitroModules
3
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 = ""
4
+ /// Per-run callback target, handed to the core as the context pointer. The
5
+ /// core only routes callbacks to whichever session actually holds its
6
+ /// execution lock, so concurrently submitted sessions never see each other's
7
+ /// logs.
8
+ private final class Session {
9
+ let onLog: ((String) -> Void)?
10
+ let onStatistics: ((Double, Double, Double, Double, Double, Double, Double) -> Void)?
11
+ private var buffer = ""
12
+ // FFmpeg logs from several of its own threads at once, so the buffer needs
13
+ // the lock even though each session belongs to a single execution.
14
+ private let lock = NSLock()
15
+
16
+ init(
17
+ onLog: ((String) -> Void)?,
18
+ onStatistics: ((Double, Double, Double, Double, Double, Double, Double) -> Void)? = nil
19
+ ) {
20
+ self.onLog = onLog
21
+ self.onStatistics = onStatistics
22
+ }
23
+
24
+ func append(_ text: String) {
25
+ lock.lock()
26
+ buffer += text
27
+ lock.unlock()
28
+ }
29
+
30
+ var output: String {
31
+ lock.lock()
32
+ defer { lock.unlock() }
33
+ return buffer
15
34
  }
16
35
  }
17
36
 
18
- private let active = ActiveSession()
19
37
  // Concurrent on purpose: the C core serialises executions behind its own lock,
20
38
  // and a request that is waiting there can still be cancelled. A serial queue
21
39
  // would hold the second call outside the core, where cancelAll() cannot see it.
@@ -25,16 +43,17 @@ private let executionQueue = DispatchQueue(
25
43
  attributes: .concurrent
26
44
  )
27
45
 
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
- }
46
+ private let installCallbacks: Void = munim_ffmpeg_set_callbacks({ context, message in
47
+ guard let context, let message else { return }
48
+ let session = Unmanaged<Session>.fromOpaque(context).takeUnretainedValue()
49
+ let text = String(cString: message)
50
+ session.append(text)
51
+ session.onLog?(text)
52
+ }, { context, timeMs, sizeBytes, bitrate, speed, frame, fps, quality in
53
+ guard let context else { return }
54
+ let session = Unmanaged<Session>.fromOpaque(context).takeUnretainedValue()
55
+ session.onStatistics?(timeMs, sizeBytes, bitrate, speed, frame, fps, quality)
56
+ }, nil)
38
57
 
39
58
  final class HybridMunimFfmpeg: HybridMunimFfmpegSpec {
40
59
  private var sessionCounter: Double = 0
@@ -88,26 +107,28 @@ final class HybridMunimFfmpeg: HybridMunimFfmpegSpec {
88
107
  // its logger, so it is captured to a file and appended to the output.
89
108
  let printedPath = Self.temporaryFile()
90
109
 
91
- installCallbacks()
92
- active.reset()
93
- active.onLog = onLog
94
- active.onStatistics = onStatistics
110
+ _ = installCallbacks
111
+ let session = Session(onLog: onLog, onStatistics: onStatistics)
95
112
 
96
- let returnCode = withArrayOfCStrings(arguments_) { argv in
97
- munim_ffmpeg_execute(Int32(arguments_.count), argv, printedPath)
113
+ let returnCode = withExtendedLifetime(session) {
114
+ withArrayOfCStrings(arguments_) { argv in
115
+ munim_ffmpeg_execute_ctx(
116
+ Int32(arguments_.count),
117
+ argv,
118
+ printedPath,
119
+ Unmanaged.passUnretained(session).toOpaque()
120
+ )
121
+ }
98
122
  }
99
123
 
100
124
  let printed = (try? String(contentsOfFile: printedPath, encoding: .utf8)) ?? ""
101
125
  try? FileManager.default.removeItem(atPath: printedPath)
102
126
 
103
- let output = active.output + printed
104
- active.reset()
105
-
106
127
  promise.resolve(
107
128
  withResult: self.result(
108
129
  sessionId: sessionId,
109
130
  returnCode: returnCode,
110
- output: output,
131
+ output: session.output + printed,
111
132
  startedAt: startedAt
112
133
  )
113
134
  )
@@ -178,21 +199,24 @@ final class HybridMunimFfmpeg: HybridMunimFfmpegSpec {
178
199
  ) -> (Int32, String) {
179
200
  let destination = temporaryFile()
180
201
 
181
- installCallbacks()
182
- active.reset()
183
- active.onLog = onLog
202
+ _ = installCallbacks
203
+ let session = Session(onLog: onLog)
184
204
 
185
- let returnCode = withArrayOfCStrings(arguments) { argv in
186
- munim_ffmpeg_probe(Int32(arguments.count), argv, destination)
205
+ let returnCode = withExtendedLifetime(session) {
206
+ withArrayOfCStrings(arguments) { argv in
207
+ munim_ffmpeg_probe_ctx(
208
+ Int32(arguments.count),
209
+ argv,
210
+ destination,
211
+ Unmanaged.passUnretained(session).toOpaque()
212
+ )
213
+ }
187
214
  }
188
215
 
189
216
  let report = (try? String(contentsOfFile: destination, encoding: .utf8)) ?? ""
190
217
  try? FileManager.default.removeItem(atPath: destination)
191
218
 
192
- let logs = active.output
193
- active.reset()
194
-
195
- return (returnCode, report.isEmpty ? logs : report)
219
+ return (returnCode, report.isEmpty ? session.output : report)
196
220
  }
197
221
 
198
222
  func cancel(sessionId: Double?) throws {
@@ -21,7 +21,12 @@ typedef void (*munim_statistics_callback)(void *context, double time_ms,
21
21
  /** Version string of the linked FFmpeg, e.g. "9.0.1". */
22
22
  const char *munim_ffmpeg_version(void);
23
23
 
24
- /** Callbacks apply to whichever execution is currently running. */
24
+ /**
25
+ * Installs the callback functions. `context` is the default value handed to
26
+ * them; an execution started through one of the `_ctx` variants overrides it
27
+ * for exactly as long as that execution holds the core's lock, which is how
28
+ * callers waiting concurrently keep their callbacks apart.
29
+ */
25
30
  void munim_ffmpeg_set_callbacks(munim_log_callback on_log,
26
31
  munim_statistics_callback on_statistics,
27
32
  void *context);
@@ -38,10 +43,18 @@ void munim_ffmpeg_set_callbacks(munim_log_callback on_log,
38
43
  int munim_ffmpeg_execute(int argc, const char *const *argv,
39
44
  const char *stdout_path);
40
45
 
46
+ /** Like munim_ffmpeg_execute, with a per-run callback context. */
47
+ int munim_ffmpeg_execute_ctx(int argc, const char *const *argv,
48
+ const char *stdout_path, void *session);
49
+
41
50
  /** Runs `ffprobe`, writing its report to `output_path` via `-o`. */
42
51
  int munim_ffmpeg_probe(int argc, const char *const *argv,
43
52
  const char *output_path);
44
53
 
54
+ /** Like munim_ffmpeg_probe, with a per-run callback context. */
55
+ int munim_ffmpeg_probe_ctx(int argc, const char *const *argv,
56
+ const char *output_path, void *session);
57
+
45
58
  /**
46
59
  * Requests cancellation of the running execution, and of any execution already
47
60
  * queued behind it.
package/lib/index.d.ts CHANGED
@@ -21,6 +21,14 @@ export declare function getFFmpegVersion(): string;
21
21
  export declare function listEncoders(): Promise<string[]>;
22
22
  /** Decoder names the bundled FFmpeg build can read, e.g. `h264`. */
23
23
  export declare function listDecoders(): Promise<string[]>;
24
+ /** Container formats the bundled FFmpeg build can write, e.g. `matroska`. */
25
+ export declare function listMuxers(): Promise<string[]>;
26
+ /** Container formats the bundled FFmpeg build can read, e.g. `matroska`. */
27
+ export declare function listDemuxers(): Promise<string[]>;
28
+ /** Filter names the bundled FFmpeg build provides, e.g. `subtitles`. */
29
+ export declare function listFilters(): Promise<string[]>;
30
+ /** Protocol names the bundled FFmpeg build provides, e.g. `https`. */
31
+ export declare function listProtocols(): Promise<string[]>;
24
32
  /**
25
33
  * Returns the first available encoder from `candidates`, so one command can
26
34
  * run on both platforms:
package/lib/index.js CHANGED
@@ -39,9 +39,9 @@ export function cancelAll() {
39
39
  export function getFFmpegVersion() {
40
40
  return MunimFfmpeg.ffmpegVersion;
41
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.
42
+ // The bundled FFmpeg builds differ per platform: Android ships the MediaCodec
43
+ // hardware encoders, iOS ships VideoToolbox. Asking the binary what it
44
+ // supports is more reliable than hard-coding a per-platform table.
45
45
  const codecCache = new Map();
46
46
  function listCodecs(flag) {
47
47
  const cached = codecCache.get(flag);
@@ -76,6 +76,80 @@ export function listEncoders() {
76
76
  export function listDecoders() {
77
77
  return listCodecs('-decoders');
78
78
  }
79
+ // Muxers, demuxers and filters print with different flag columns than codecs,
80
+ // and protocols use indented sections instead of a table, so each report gets
81
+ // its own parser. All of them are cached like the codec lists.
82
+ const reportCache = new Map();
83
+ function listReport(flag, parse) {
84
+ const cached = reportCache.get(flag);
85
+ if (cached)
86
+ return cached;
87
+ const request = execute(['-hide_banner', flag])
88
+ .then((result) => {
89
+ if (!result.success) {
90
+ throw new Error(result.failStackTrace ?? result.output);
91
+ }
92
+ return parse(result.output);
93
+ })
94
+ .catch((error) => {
95
+ reportCache.delete(flag);
96
+ throw error;
97
+ });
98
+ reportCache.set(flag, request);
99
+ return request;
100
+ }
101
+ // `-muxers`/`-demuxers` entries look like ` E mp4 MP4 (MPEG-4 Part 14)`
102
+ // below a `--` separator line.
103
+ function parseFormats(output) {
104
+ const body = output.split(/^\s*-+\s*$/m).pop() ?? '';
105
+ return body
106
+ .split('\n')
107
+ .map((line) => line.trim().split(/\s+/))
108
+ .filter((columns) => columns.length >= 2 && /^[DE.]{1,2}$/.test(columns[0]))
109
+ .flatMap((columns) => columns[1].split(','));
110
+ }
111
+ // `-filters` entries look like ` TS scale V->V Scale the input video.`
112
+ // The flag column has held two or three characters across FFmpeg releases.
113
+ function parseFilters(output) {
114
+ return output
115
+ .split('\n')
116
+ .map((line) => line.trim().split(/\s+/))
117
+ .filter((columns) => columns.length >= 3 &&
118
+ /^[TSC.]{2,3}$/.test(columns[0]) &&
119
+ /->/.test(columns[2]))
120
+ .map((columns) => columns[1]);
121
+ }
122
+ // `-protocols` prints `Input:` and `Output:` sections of indented names.
123
+ function parseProtocols(output) {
124
+ const names = new Set();
125
+ let inSection = false;
126
+ for (const line of output.split('\n')) {
127
+ if (/^(Input|Output):/.test(line.trim())) {
128
+ inSection = true;
129
+ continue;
130
+ }
131
+ const name = line.trim();
132
+ if (inSection && /^[a-z0-9_]+$/.test(name))
133
+ names.add(name);
134
+ }
135
+ return [...names];
136
+ }
137
+ /** Container formats the bundled FFmpeg build can write, e.g. `matroska`. */
138
+ export function listMuxers() {
139
+ return listReport('-muxers', parseFormats);
140
+ }
141
+ /** Container formats the bundled FFmpeg build can read, e.g. `matroska`. */
142
+ export function listDemuxers() {
143
+ return listReport('-demuxers', parseFormats);
144
+ }
145
+ /** Filter names the bundled FFmpeg build provides, e.g. `subtitles`. */
146
+ export function listFilters() {
147
+ return listReport('-filters', parseFilters);
148
+ }
149
+ /** Protocol names the bundled FFmpeg build provides, e.g. `https`. */
150
+ export function listProtocols() {
151
+ return listReport('-protocols', parseProtocols);
152
+ }
79
153
  /**
80
154
  * Returns the first available encoder from `candidates`, so one command can
81
155
  * run on both platforms:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "munim-ffmpeg",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
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",
@@ -78,6 +78,11 @@
78
78
  "workspaces": [
79
79
  "example"
80
80
  ],
81
+ "overrides": {
82
+ "xcode": {
83
+ "uuid": "^11.1.1"
84
+ }
85
+ },
81
86
  "repository": {
82
87
  "type": "git",
83
88
  "url": "git+https://github.com/munimtechnologies/munim-ffmpeg.git"
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "archive": "munim-ffmpeg-binaries.tar.gz",
3
- "sha256": "e78e41e80741ec747d148e00749e2f689b96b1b7efa51dc7d6f65d4fc0ab7463",
3
+ "sha256": "e206478796cacf79936c4e1f8a5d969e5063f890cff10badfd13f39a67626f37",
4
4
  "ffmpeg": "9.0.1"
5
5
  }
File without changes
package/src/index.ts CHANGED
@@ -84,9 +84,9 @@ export function getFFmpegVersion(): string {
84
84
  return MunimFfmpeg.ffmpegVersion
85
85
  }
86
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.
87
+ // The bundled FFmpeg builds differ per platform: Android ships the MediaCodec
88
+ // hardware encoders, iOS ships VideoToolbox. Asking the binary what it
89
+ // supports is more reliable than hard-coding a per-platform table.
90
90
  const codecCache = new Map<string, Promise<string[]>>()
91
91
 
92
92
  function listCodecs(flag: '-encoders' | '-decoders'): Promise<string[]> {
@@ -129,6 +129,97 @@ export function listDecoders(): Promise<string[]> {
129
129
  return listCodecs('-decoders')
130
130
  }
131
131
 
132
+ // Muxers, demuxers and filters print with different flag columns than codecs,
133
+ // and protocols use indented sections instead of a table, so each report gets
134
+ // its own parser. All of them are cached like the codec lists.
135
+ const reportCache = new Map<string, Promise<string[]>>()
136
+
137
+ function listReport(
138
+ flag: string,
139
+ parse: (output: string) => string[]
140
+ ): Promise<string[]> {
141
+ const cached = reportCache.get(flag)
142
+ if (cached) return cached
143
+
144
+ const request = execute(['-hide_banner', flag])
145
+ .then((result) => {
146
+ if (!result.success) {
147
+ throw new Error(result.failStackTrace ?? result.output)
148
+ }
149
+ return parse(result.output)
150
+ })
151
+ .catch((error) => {
152
+ reportCache.delete(flag)
153
+ throw error
154
+ })
155
+
156
+ reportCache.set(flag, request)
157
+ return request
158
+ }
159
+
160
+ // `-muxers`/`-demuxers` entries look like ` E mp4 MP4 (MPEG-4 Part 14)`
161
+ // below a `--` separator line.
162
+ function parseFormats(output: string): string[] {
163
+ const body = output.split(/^\s*-+\s*$/m).pop() ?? ''
164
+ return body
165
+ .split('\n')
166
+ .map((line) => line.trim().split(/\s+/))
167
+ .filter(
168
+ (columns) => columns.length >= 2 && /^[DE.]{1,2}$/.test(columns[0]!)
169
+ )
170
+ .flatMap((columns) => columns[1]!.split(','))
171
+ }
172
+
173
+ // `-filters` entries look like ` TS scale V->V Scale the input video.`
174
+ // The flag column has held two or three characters across FFmpeg releases.
175
+ function parseFilters(output: string): string[] {
176
+ return output
177
+ .split('\n')
178
+ .map((line) => line.trim().split(/\s+/))
179
+ .filter(
180
+ (columns) =>
181
+ columns.length >= 3 &&
182
+ /^[TSC.]{2,3}$/.test(columns[0]!) &&
183
+ /->/.test(columns[2]!)
184
+ )
185
+ .map((columns) => columns[1]!)
186
+ }
187
+
188
+ // `-protocols` prints `Input:` and `Output:` sections of indented names.
189
+ function parseProtocols(output: string): string[] {
190
+ const names = new Set<string>()
191
+ let inSection = false
192
+ for (const line of output.split('\n')) {
193
+ if (/^(Input|Output):/.test(line.trim())) {
194
+ inSection = true
195
+ continue
196
+ }
197
+ const name = line.trim()
198
+ if (inSection && /^[a-z0-9_]+$/.test(name)) names.add(name)
199
+ }
200
+ return [...names]
201
+ }
202
+
203
+ /** Container formats the bundled FFmpeg build can write, e.g. `matroska`. */
204
+ export function listMuxers(): Promise<string[]> {
205
+ return listReport('-muxers', parseFormats)
206
+ }
207
+
208
+ /** Container formats the bundled FFmpeg build can read, e.g. `matroska`. */
209
+ export function listDemuxers(): Promise<string[]> {
210
+ return listReport('-demuxers', parseFormats)
211
+ }
212
+
213
+ /** Filter names the bundled FFmpeg build provides, e.g. `subtitles`. */
214
+ export function listFilters(): Promise<string[]> {
215
+ return listReport('-filters', parseFilters)
216
+ }
217
+
218
+ /** Protocol names the bundled FFmpeg build provides, e.g. `https`. */
219
+ export function listProtocols(): Promise<string[]> {
220
+ return listReport('-protocols', parseProtocols)
221
+ }
222
+
132
223
  /**
133
224
  * Returns the first available encoder from `candidates`, so one command can
134
225
  * run on both platforms: