munim-ffmpeg 0.5.0 → 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.
package/README.md CHANGED
@@ -132,8 +132,8 @@
132
132
  | Expo Go | ❌ | ❌ | A native development build is required. |
133
133
  | Capability discovery | ✅ | ✅ | `listEncoders()`, `listDecoders()`, `listMuxers()`, `listDemuxers()`, `listFilters()`, `listProtocols()`, and `pickEncoder()` report what the bundled build supports. |
134
134
  | Subtitle burn-in | ✅ | ✅ | libass with system fonts: Core Text on iOS, fontconfig over `/system/fonts` on Android. |
135
- | H.264 encoding | VideoToolbox | libx264 | The builds differ; use `pickEncoder(['libx264', 'h264_videotoolbox'])` instead of hard-coding an encoder. |
136
- | Remote HTTP(S) inputs | ✅ | ✅ | Both builds link GnuTLS. Remote server behaviour still varies; prefer local files for predictable app workflows. |
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. |
137
137
 
138
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.
139
139
 
@@ -567,11 +567,12 @@ if (!result.success) {
567
567
  ```typescript
568
568
  import { execute, pickEncoder } from 'munim-ffmpeg'
569
569
 
570
- const encoder = await pickEncoder(['libx264', 'h264_videotoolbox'])
570
+ const encoder = await pickEncoder(['h264_videotoolbox', 'h264_mediacodec', 'libopenh264'])
571
571
  if (!encoder) throw new Error('No H.264 encoder available in this build')
572
572
 
573
- // -preset is an x264 option; VideoToolbox rejects it.
574
- 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]
575
576
 
576
577
  const result = await execute([
577
578
  '-y',
@@ -741,7 +742,7 @@ Rebuild the native app after installing both `munim-ffmpeg` and `react-native-ni
741
742
 
742
743
  ### A codec or filter is missing
743
744
 
744
- 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).
745
746
 
746
747
  ### The Promise resolved but the command failed
747
748
 
@@ -5,6 +5,55 @@ import androidx.annotation.Keep
5
5
  import com.facebook.proguard.annotations.DoNotStrip
6
6
  import java.io.File
7
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
+ }
56
+
8
57
  /**
9
58
  * Thin wrapper over FFmpeg 9's own command-line tools, compiled to run inside
10
59
  * the app process.
@@ -21,6 +70,25 @@ object FFmpegNative {
21
70
  System.loadLibrary("munimffmpeg9")
22
71
  }
23
72
 
73
+ /** Return code the tools report when a run was cancelled. */
74
+ const val CANCELLED = 255
75
+
76
+ external fun nativeVersion(): String
77
+
78
+ external fun nativeExecute(
79
+ arguments: Array<String>,
80
+ stdoutPath: String,
81
+ session: FFmpegSession,
82
+ ): Int
83
+
84
+ external fun nativeExecuteProbe(
85
+ arguments: Array<String>,
86
+ outputPath: String,
87
+ session: FFmpegSession,
88
+ ): Int
89
+
90
+ external fun nativeCancel()
91
+
24
92
  /**
25
93
  * libass discovers fonts through fontconfig, and Android has no fonts.conf,
26
94
  * so one pointing at the system font directories is written to the app's
@@ -53,70 +121,4 @@ object FFmpegNative {
53
121
  Os.setenv("FONTCONFIG_FILE", configuration.absolutePath, true)
54
122
  }
55
123
  }
56
-
57
- /** Return code the tools report when a run was cancelled. */
58
- const val CANCELLED = 255
59
-
60
- external fun nativeVersion(): String
61
-
62
- external fun nativeExecute(arguments: Array<String>, stdoutPath: String): Int
63
-
64
- external fun nativeExecuteProbe(arguments: Array<String>, outputPath: String): Int
65
-
66
- external fun nativeCancel()
67
-
68
- data class Statistics(
69
- val timeMs: Double,
70
- val sizeBytes: Double,
71
- val bitrateKbits: Double,
72
- val speed: Double,
73
- val videoFrameNumber: Double,
74
- val fps: Double,
75
- val quality: Double,
76
- )
77
-
78
- @Volatile
79
- private var logSink: ((String) -> Unit)? = null
80
-
81
- @Volatile
82
- private var statisticsSink: ((Statistics) -> Unit)? = null
83
-
84
- fun <T> withCallbacks(
85
- onLog: ((String) -> Unit)?,
86
- onStatistics: ((Statistics) -> Unit)?,
87
- body: () -> T,
88
- ): T {
89
- logSink = onLog
90
- statisticsSink = onStatistics
91
- try {
92
- return body()
93
- } finally {
94
- logSink = null
95
- statisticsSink = null
96
- }
97
- }
98
-
99
- @JvmStatic
100
- @Keep
101
- @DoNotStrip
102
- fun onLog(message: String) {
103
- logSink?.invoke(message)
104
- }
105
-
106
- @JvmStatic
107
- @Keep
108
- @DoNotStrip
109
- fun onStatistics(
110
- timeMs: Double,
111
- sizeBytes: Double,
112
- bitrateKbits: Double,
113
- speed: Double,
114
- videoFrameNumber: Double,
115
- fps: Double,
116
- quality: Double,
117
- ) {
118
- statisticsSink?.invoke(
119
- Statistics(timeMs, sizeBytes, bitrateKbits, speed, videoFrameNumber, fps, quality)
120
- )
121
- }
122
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.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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "munim-ffmpeg",
3
- "version": "0.5.0",
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",
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "archive": "munim-ffmpeg-binaries.tar.gz",
3
- "sha256": "05bb0a21348ae3958ca8b06fe6e31bfe2048277f6c6a4720271ce53a8973b653",
3
+ "sha256": "e206478796cacf79936c4e1f8a5d969e5063f890cff10badfd13f39a67626f37",
4
4
  "ffmpeg": "9.0.1"
5
5
  }
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[]> {