react-native-video-trim 8.1.1 → 8.1.2

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
@@ -567,6 +567,7 @@ These options only apply when `type: 'audio'`. The waveform replaces the thumbna
567
567
  | Option | Type | Default | Description |
568
568
  |--------|------|---------|-------------|
569
569
  | `enableHapticFeedback` | `boolean` | `true` | Enable haptic feedback |
570
+ | `enableEditTools` | `boolean` | `true` | Show the top toolbar of edit tools (flip, rotate, crop, mute, speed, undo, redo). Set to `false` to hide the entire toolbar. Video only |
570
571
  | `closeWhenFinish` | `boolean` | `true` | Close editor when done |
571
572
  | `enablePreciseTrimming` | `boolean` | `false` | Re-encode for frame-accurate cuts (slower, see [Precise Frame Trimming](#precise-frame-trimming)) |
572
573
  | `changeStatusBarColorOnOpen` | `boolean` | `false` | Change status bar color (Android only) |
@@ -760,6 +761,21 @@ The editor includes built-in transform controls — horizontal flip, 90° left r
760
761
 
761
762
  When any transform is applied, FFmpeg automatically re-encodes the video using the platform's hardware encoder (`h264_videotoolbox` on iOS, `h264_mediacodec` on Android) at the source bitrate to preserve quality. No additional configuration is needed.
762
763
 
764
+ ### Android encoder compatibility (auto fallback)
765
+
766
+ On Android, a small number of devices ship a hardware H.264 encoder (`h264_mediacodec`) that refuses to configure for valid H.264 inputs — typically with `MediaCodec configure failed, Generic error in an external library` (e.g. LG G8 ThinQ on Snapdragon 855, and other older Qualcomm/MediaTek chipsets). This affects only the re-encode path (transform / crop / `enablePreciseTrimming` / non-`1.0` speed); plain trims use stream copy and never touch an encoder.
767
+
768
+ The library handles this automatically with a two-step encoder fallback chain:
769
+
770
+ 1. `h264_mediacodec` — hardware H.264, fast, default. Used on every device first.
771
+ 2. `mpeg4 -q:v 3` — software MPEG-4 Part 2, always available. Only used if attempt 1 fails at `configure()` with a hardware-encoder signature. Lower visual quality and larger files than H.264, but guaranteed to work.
772
+
773
+ No configuration is needed — the fallback is transparent. When a retry happens, a notice is emitted via the existing `onLog` event (`Hardware encoder failed; retrying with software encoder fallback`) so you can observe quality degradation if you want to log it.
774
+
775
+ If every attempt in the chain fails, `onError` is emitted with `errorCode: "HARDWARE_ENCODER_FAILED"` (instead of the generic `"TRIMMING_FAILED"`) so apps can present a more specific message or telemetry.
776
+
777
+ > iOS does not need this fallback — `h264_videotoolbox` runs against Apple's single-vendor stack and has no known reproducible configure-time failures on supported devices. The `HARDWARE_ENCODER_FAILED` `errorCode` is still emitted on iOS as a defensive measure if the rare VideoToolbox failure does occur.
778
+
763
779
  ### Precise Frame Trimming
764
780
 
765
781
  By default, trimming uses FFmpeg's stream copy (`-c copy`), which is very fast but can only cut at keyframes. The actual start/end points may drift by several seconds from what the user selected.
@@ -634,111 +634,117 @@ open class BaseVideoTrimModule internal constructor(
634
634
  options.getBoolean("enablePreciseTrimming")
635
635
  val needsReEncode = enablePrecise || speed != 1.0
636
636
 
637
- val cmds = mutableListOf(
638
- "-ss",
639
- "${startTime}ms",
640
- "-to",
641
- "${endTime}ms",
637
+ // Headless trim still benefits from the encoder fallback chain whenever it
638
+ // re-encodes (hardware encoder failures are device-specific, not path-specific).
639
+ // No-op log/stats/progress callbacks because headless trim returns via Promise
640
+ // rather than emitting events.
641
+ val onSuccess: () -> Unit = {
642
+ val duration = endTime - startTime
643
+ val result = Arguments.createMap()
644
+ result.putString("outputPath", resolvedOutputFile)
645
+ result.putDouble("startTime", startTime)
646
+ result.putDouble("endTime", endTime)
647
+ result.putDouble("duration", duration)
648
+ result.putBoolean("success", true)
649
+
650
+ if (options?.getBoolean("saveToPhoto") == true && options.getString("type") == "video") {
651
+ Log.d(TAG, "Android trim: saveToPhoto is true, attempting to save to gallery")
652
+ try {
653
+ StorageUtil.saveToGallery(reactApplicationContext, resolvedOutputFile)
654
+ Log.d(TAG, "Edited video saved to Photo Library successfully.")
655
+ if (options.getBoolean("removeAfterSavedToPhoto")) {
656
+ Log.d(TAG, "Removing file after successful save to photo")
657
+ StorageUtil.deleteFile(resolvedOutputFile)
658
+ }
659
+ promise.resolve(result)
660
+ } catch (e: IOException) {
661
+ e.printStackTrace()
662
+ if (options.getBoolean("removeAfterFailedToSavePhoto")) {
663
+ Log.d(TAG, "Removing file after failed save to photo")
664
+ StorageUtil.deleteFile(resolvedOutputFile)
665
+ }
666
+ promise.reject(
667
+ Exception("Failed to save edited video to Photo Library: " + e.localizedMessage)
668
+ )
669
+ }
670
+ } else {
671
+ Log.d(TAG, "Android trim: saveToPhoto is false or not video type, resolving with structured result")
672
+ promise.resolve(result)
673
+ }
674
+ }
675
+
676
+ val callbacks = VideoTrimmerUtil.TrimCallbacks(
677
+ onLog = { msg -> msg.getString("message")?.let { Log.d(TAG, it) } },
678
+ onStatistics = { /* headless trim does not surface statistics */ },
679
+ onProgress = { /* headless trim does not surface progress */ },
680
+ onSuccess = onSuccess,
681
+ onCancel = {
682
+ println("FFmpeg command was cancelled")
683
+ promise.reject(Exception("FFmpeg command was cancelled"))
684
+ },
685
+ onError = { errorMessage, _ ->
686
+ Log.d(TAG, errorMessage)
687
+ promise.reject(Exception(errorMessage))
688
+ },
642
689
  )
643
690
 
644
- if (needsReEncode) {
645
- var bitrateStr = "10M"
646
- try {
647
- val retriever = MediaMetadataRetriever()
648
- retriever.setDataSource(reactApplicationContext, Uri.parse(url))
649
- val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
650
- ?.toLongOrNull() ?: 0L
651
- if (bitrate > 0) bitrateStr = "$bitrate"
652
- retriever.release()
653
- } catch (_: Exception) {}
691
+ if (!needsReEncode) {
692
+ // Stream copy: no encoder is opened so the fallback chain doesn't apply.
693
+ val cmds = mutableListOf(
694
+ "-ss", "${startTime}ms",
695
+ "-to", "${endTime}ms",
696
+ "-i", url,
697
+ )
698
+ if (removeAudio) {
699
+ cmds.addAll(listOf("-c:v", "copy", "-an"))
700
+ } else {
701
+ cmds.addAll(listOf("-c", "copy"))
702
+ }
703
+ cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", resolvedOutputFile))
704
+ VideoTrimmerUtil.executeWithEncoderFallback(
705
+ encoderConfigs = listOf(emptyList()),
706
+ buildCommand = { cmds.toTypedArray() },
707
+ videoDurationMs = 0,
708
+ callbacks = callbacks,
709
+ )
710
+ return
711
+ }
712
+
713
+ var bitrateStr = "10M"
714
+ try {
715
+ val retriever = MediaMetadataRetriever()
716
+ retriever.setDataSource(reactApplicationContext, Uri.parse(url))
717
+ val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
718
+ ?.toLongOrNull() ?: 0L
719
+ if (bitrate > 0) bitrateStr = "$bitrate"
720
+ retriever.release()
721
+ } catch (_: Exception) {}
654
722
 
655
- cmds.addAll(listOf("-i", url))
723
+ val buildCommand: (List<String>) -> Array<String> = { encoderArgs ->
724
+ val cmds = mutableListOf(
725
+ "-ss", "${startTime}ms",
726
+ "-to", "${endTime}ms",
727
+ "-i", url,
728
+ )
656
729
  if (speed != 1.0) {
657
730
  cmds.addAll(listOf("-vf", "setpts=${1.0 / speed}*PTS"))
658
731
  }
659
- cmds.addAll(listOf("-c:v", "h264_mediacodec", "-b:v", bitrateStr))
732
+ cmds.addAll(encoderArgs)
660
733
  when {
661
734
  removeAudio -> cmds.add("-an")
662
735
  speed != 1.0 -> cmds.addAll(listOf("-af", VideoTrimmerUtil.buildAtempoChain(speed)))
663
736
  else -> cmds.addAll(listOf("-c:a", "copy"))
664
737
  }
665
738
  cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", resolvedOutputFile))
666
- } else {
667
- cmds.addAll(listOf("-i", url))
668
- if (removeAudio) {
669
- cmds.addAll(listOf("-c:v", "copy", "-an"))
670
- } else {
671
- cmds.addAll(listOf("-c", "copy"))
672
- }
673
- cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", resolvedOutputFile))
739
+ cmds.toTypedArray()
674
740
  }
675
741
 
676
- Log.d(TAG, "Command: ${cmds.joinToString(" ")}")
677
-
678
- FFmpegKit.executeWithArgumentsAsync(cmds.toTypedArray(), { session ->
679
- val state = session.state
680
- val returnCode = session.returnCode
681
- UiThreadUtil.runOnUiThread {
682
- when {
683
- ReturnCode.isSuccess(returnCode) -> {
684
- val duration = endTime - startTime
685
- val result = Arguments.createMap()
686
-
687
- result.putString("outputPath", resolvedOutputFile)
688
- result.putDouble("startTime", startTime)
689
- result.putDouble("endTime", endTime)
690
- result.putDouble("duration", duration)
691
- result.putBoolean("success", true)
692
-
693
- if (options?.getBoolean("saveToPhoto") == true && options.getString("type") == "video") {
694
- Log.d(TAG, "Android trim: saveToPhoto is true, attempting to save to gallery")
695
- try {
696
- StorageUtil.saveToGallery(reactApplicationContext, resolvedOutputFile)
697
- Log.d(TAG, "Edited video saved to Photo Library successfully.")
698
- if (options.getBoolean("removeAfterSavedToPhoto")) {
699
- Log.d(TAG, "Removing file after successful save to photo")
700
- StorageUtil.deleteFile(resolvedOutputFile)
701
- }
702
-
703
- promise.resolve(result)
704
- } catch (e: IOException) {
705
- e.printStackTrace()
706
-
707
- if (options.getBoolean("removeAfterFailedToSavePhoto")) {
708
- Log.d(TAG, "Removing file after failed save to photo")
709
- StorageUtil.deleteFile(resolvedOutputFile)
710
- }
711
-
712
- promise.reject(
713
- Exception("Failed to save edited video to Photo Library: " + e.localizedMessage)
714
- )
715
- }
716
- } else {
717
- Log.d(TAG, "Android trim: saveToPhoto is false or not video type, resolving with structured result")
718
-
719
- promise.resolve(result)
720
- }
721
- }
722
- ReturnCode.isCancel(returnCode) -> {
723
- println("FFmpeg command was cancelled")
724
- promise.reject(
725
- Exception("FFmpeg command was cancelled with code $returnCode")
726
- )
727
- }
728
- else -> {
729
- val errorMessage = String.format("Command failed with state %s and rc %s.%s", state, returnCode, session.getFailStackTrace())
730
- Log.d(TAG, errorMessage)
731
- promise.reject(
732
- Exception(errorMessage)
733
- )
734
- }
735
- }
736
- }
737
- }, { log ->
738
- Log.d(TAG, "FFmpeg process started with log ${log.message}")
739
- }, { statistics ->
740
- // Handle statistics if needed
741
- })
742
+ VideoTrimmerUtil.executeWithEncoderFallback(
743
+ encoderConfigs = VideoTrimmerUtil.reEncodeEncoderConfigs(bitrateStr),
744
+ buildCommand = buildCommand,
745
+ videoDurationMs = 0,
746
+ callbacks = callbacks,
747
+ )
742
748
  }
743
749
 
744
750
  // Extracts a single video frame as JPEG/PNG. Output goes to the cache directory.
@@ -2,6 +2,7 @@ package com.videotrim.enums
2
2
 
3
3
  enum class ErrorCode {
4
4
  TRIMMING_FAILED,
5
+ HARDWARE_ENCODER_FAILED,
5
6
  FAIL_TO_GET_VIDEO_INFO,
6
7
  FAIL_TO_INITIALIZE_AUDIO_PLAYER,
7
8
  FAIL_TO_LOAD_MEDIA,
@@ -10,6 +10,7 @@ import com.arthenica.ffmpegkit.FFmpegSession
10
10
  import com.arthenica.ffmpegkit.ReturnCode
11
11
  import com.facebook.react.bridge.Arguments
12
12
  import com.facebook.react.bridge.UiThreadUtil
13
+ import com.facebook.react.bridge.WritableMap
13
14
  import com.videotrim.enums.ErrorCode
14
15
  import com.videotrim.interfaces.VideoTrimListener
15
16
  import iknow.android.utils.DeviceUtil
@@ -21,6 +22,28 @@ import java.util.Date
21
22
  import java.util.TimeZone
22
23
  import kotlin.math.roundToInt
23
24
 
25
+ /**
26
+ * Handle returned from FFmpeg trim invocations that own an encoder-fallback chain.
27
+ *
28
+ * A single trim request may span multiple FFmpeg sessions when the first encoder
29
+ * (typically [h264_mediacodec]) fails at configure-time on a quirky hardware
30
+ * encoder and we retry with a software fallback. The handle tracks the currently
31
+ * running session so callers can cancel without caring which attempt is active.
32
+ */
33
+ class TrimSession {
34
+ @Volatile private var currentSession: FFmpegSession? = null
35
+ @Volatile internal var cancelled = false
36
+
37
+ internal fun setCurrentSession(session: FFmpegSession?) {
38
+ currentSession = session
39
+ }
40
+
41
+ fun cancel() {
42
+ cancelled = true
43
+ currentSession?.cancel()
44
+ }
45
+ }
46
+
24
47
  object VideoTrimmerUtil {
25
48
 
26
49
  private val TAG: String = VideoTrimmerUtil::class.java.simpleName
@@ -53,6 +76,186 @@ object VideoTrimmerUtil {
53
76
  return filters.joinToString(",")
54
77
  }
55
78
 
79
+ /**
80
+ * Encoder fallback chain used when a re-encode is required. Tried in order
81
+ * until one succeeds at FFmpeg's configure() step.
82
+ *
83
+ * 1. `h264_mediacodec` — Android's hardware H.264 encoder. Fastest and the
84
+ * default. Fails on some quirky Qualcomm/MediaTek implementations with
85
+ * `MediaCodec configure failed` (see "Android encoder compatibility" in
86
+ * README).
87
+ * 2. `mpeg4` — software MPEG-4 Part 2 (Simple Profile). Always available in
88
+ * every FFmpegKit build, succeeds on every device. Produces larger files
89
+ * at lower visual quality than H.264, used only as a last resort when
90
+ * the hardware encoder rejects the configuration.
91
+ *
92
+ * The chain only matters for the re-encode branch (transform/crop/precise/
93
+ * speed); the default stream-copy path doesn't open an encoder at all.
94
+ */
95
+ internal fun reEncodeEncoderConfigs(bitrateStr: String): List<List<String>> = listOf(
96
+ listOf("-c:v", "h264_mediacodec", "-b:v", bitrateStr),
97
+ listOf("-c:v", "mpeg4", "-q:v", "3")
98
+ )
99
+
100
+ /**
101
+ * Scan an FFmpeg session's log for signatures that indicate the hardware
102
+ * H.264 encoder (MediaCodec) refused to configure on this device. These
103
+ * lines come from FFmpeg's mediacodec wrapper:
104
+ *
105
+ * `[amediacodec @ ...] android.media.MediaCodec$CodecException: Error 0xffffffc3`
106
+ * `[h264_mediacodec @ ...] MediaCodec configure failed, Generic error in an external library`
107
+ *
108
+ * When this happens we re-issue the command with the next encoder in the
109
+ * fallback chain instead of surfacing the failure to the user.
110
+ */
111
+ internal fun classifyFFmpegError(session: FFmpegSession): ErrorCode {
112
+ val logs = try { session.allLogsAsString ?: "" } catch (_: Exception) { "" }
113
+ val hardwareEncoderSignals = listOf(
114
+ "MediaCodec configure failed",
115
+ "Error initializing output stream",
116
+ )
117
+ val matchedHardwareSignal = hardwareEncoderSignals.any { logs.contains(it) }
118
+ if (matchedHardwareSignal && logs.contains("mediacodec")) {
119
+ return ErrorCode.HARDWARE_ENCODER_FAILED
120
+ }
121
+ return ErrorCode.TRIMMING_FAILED
122
+ }
123
+
124
+ /**
125
+ * Callbacks used by [executeWithEncoderFallback]. Per-attempt log and
126
+ * statistics are forwarded as-is; the success/cancel/error callbacks fire
127
+ * exactly once for the overall trim request after the fallback chain
128
+ * settles.
129
+ */
130
+ internal class TrimCallbacks(
131
+ val onLog: (WritableMap) -> Unit,
132
+ val onStatistics: (WritableMap) -> Unit,
133
+ val onProgress: (Int) -> Unit,
134
+ val onSuccess: () -> Unit,
135
+ val onCancel: () -> Unit,
136
+ val onError: (String, ErrorCode) -> Unit,
137
+ )
138
+
139
+ /**
140
+ * Execute an FFmpeg trim command with an encoder-fallback chain.
141
+ *
142
+ * The command is built lazily per attempt by [buildCommand], which receives
143
+ * the current encoder args (e.g. `-c:v h264_mediacodec -b:v 8000000`) and
144
+ * returns the full argv. This lets each attempt swap only the encoder
145
+ * portion while reusing the same input/filter/output args.
146
+ *
147
+ * On non-success non-cancel completion, the session log is scanned via
148
+ * [classifyFFmpegError]. If it looks like a hardware encoder rejection and
149
+ * there are more encoders in the chain, we silently retry. Otherwise the
150
+ * error is surfaced via [TrimCallbacks.onError] with the classified code.
151
+ *
152
+ * Returns a [TrimSession] handle whose [TrimSession.cancel] cancels the
153
+ * currently running attempt and prevents any further attempts.
154
+ */
155
+ internal fun executeWithEncoderFallback(
156
+ encoderConfigs: List<List<String>>,
157
+ buildCommand: (encoderArgs: List<String>) -> Array<String>,
158
+ videoDurationMs: Int,
159
+ callbacks: TrimCallbacks,
160
+ ): TrimSession {
161
+ val trimSession = TrimSession()
162
+ runAttempt(trimSession, encoderConfigs.iterator(), buildCommand, videoDurationMs, callbacks)
163
+ return trimSession
164
+ }
165
+
166
+ private fun runAttempt(
167
+ trimSession: TrimSession,
168
+ encoderIterator: Iterator<List<String>>,
169
+ buildCommand: (encoderArgs: List<String>) -> Array<String>,
170
+ videoDurationMs: Int,
171
+ callbacks: TrimCallbacks,
172
+ ) {
173
+ if (trimSession.cancelled) {
174
+ UiThreadUtil.runOnUiThread { callbacks.onCancel() }
175
+ return
176
+ }
177
+ if (!encoderIterator.hasNext()) {
178
+ // Should be unreachable because we only retry on HARDWARE_ENCODER_FAILED;
179
+ // any non-retryable error is surfaced before exhausting the iterator.
180
+ UiThreadUtil.runOnUiThread {
181
+ callbacks.onError("Encoder fallback chain exhausted", ErrorCode.TRIMMING_FAILED)
182
+ }
183
+ return
184
+ }
185
+
186
+ val encoderArgs = encoderIterator.next()
187
+ val command = buildCommand(encoderArgs)
188
+ val cmdStr = "Command: ${command.joinToString(" ")}"
189
+ Log.d(TAG, cmdStr)
190
+ val cmdMap = Arguments.createMap()
191
+ cmdMap.putString("message", cmdStr)
192
+ callbacks.onLog(cmdMap)
193
+
194
+ val session = FFmpegKit.executeWithArgumentsAsync(command, { session ->
195
+ val state = session.state
196
+ val returnCode = session.returnCode
197
+ UiThreadUtil.runOnUiThread {
198
+ when {
199
+ ReturnCode.isSuccess(returnCode) -> callbacks.onSuccess()
200
+ ReturnCode.isCancel(returnCode) -> callbacks.onCancel()
201
+ else -> {
202
+ val classified = classifyFFmpegError(session)
203
+ if (classified == ErrorCode.HARDWARE_ENCODER_FAILED && encoderIterator.hasNext()) {
204
+ // Log fallback decision via the existing onLog channel so consumers
205
+ // can observe quality degradation without a new dedicated event.
206
+ val notice = Arguments.createMap()
207
+ notice.putString(
208
+ "message",
209
+ "Hardware encoder failed; retrying with software encoder fallback"
210
+ )
211
+ callbacks.onLog(notice)
212
+ runAttempt(trimSession, encoderIterator, buildCommand, videoDurationMs, callbacks)
213
+ } else {
214
+ val errorMessage =
215
+ "Command failed with state $state and rc $returnCode.${session.failStackTrace}"
216
+ callbacks.onError(errorMessage, classified)
217
+ }
218
+ }
219
+ }
220
+ }
221
+ }, { log ->
222
+ Log.d(TAG, "FFmpeg process started with log ${log.message}")
223
+ val map = Arguments.createMap()
224
+ map.putInt("level", log.level.value)
225
+ map.putString("message", log.message)
226
+ map.putDouble("sessionId", log.sessionId.toDouble())
227
+ map.putString("logStr", log.toString())
228
+ UiThreadUtil.runOnUiThread { callbacks.onLog(map) }
229
+ }, { statistics ->
230
+ val timeInMilliseconds = statistics.time.toInt()
231
+ if (timeInMilliseconds > 0 && videoDurationMs > 0) {
232
+ val completePercentage = (timeInMilliseconds * 100) / videoDurationMs
233
+ UiThreadUtil.runOnUiThread {
234
+ callbacks.onProgress(completePercentage.coerceIn(0, 100))
235
+ }
236
+ }
237
+ val map = Arguments.createMap()
238
+ map.putDouble("sessionId", statistics.sessionId.toDouble())
239
+ map.putInt("videoFrameNumber", statistics.videoFrameNumber)
240
+ map.putDouble("videoFps", statistics.videoFps.toDouble())
241
+ map.putDouble("videoQuality", statistics.videoQuality.toDouble())
242
+ map.putDouble("size", statistics.size.toDouble())
243
+ map.putDouble("time", statistics.time)
244
+ map.putDouble("bitrate", statistics.bitrate.toDouble())
245
+ map.putDouble("speed", statistics.speed.toDouble())
246
+ map.putString("statisticsStr", statistics.toString())
247
+ UiThreadUtil.runOnUiThread { callbacks.onStatistics(map) }
248
+ })
249
+
250
+ trimSession.setCurrentSession(session)
251
+
252
+ // If cancel arrived between the iterator check and session start, cancel
253
+ // immediately so the FFmpeg session won't run to completion.
254
+ if (trimSession.cancelled) {
255
+ session.cancel()
256
+ }
257
+ }
258
+
56
259
  fun trim(
57
260
  inputFile: String,
58
261
  outputFile: String,
@@ -69,154 +272,110 @@ object VideoTrimmerUtil {
69
272
  removeAudio: Boolean,
70
273
  speed: Double,
71
274
  callback: VideoTrimListener
72
- ): FFmpegSession {
275
+ ): TrimSession {
73
276
  val currentDate = Date()
74
277
  @SuppressLint("SimpleDateFormat")
75
278
  val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ")
76
279
  dateFormat.timeZone = TimeZone.getTimeZone("UTC")
77
280
  val formattedDateTime = dateFormat.format(currentDate)
78
281
 
79
- val cmds = mutableListOf<String>()
80
- cmds.add("-ss")
81
- cmds.add("${startMs}ms")
82
- cmds.add("-to")
83
- cmds.add("${endMs}ms")
84
-
85
282
  val hasUserTransform = userRotationCount != 0 || userIsFlipped
86
283
  // Re-encode is required when: (1) user applied flip/rotate, (2) user cropped, or
87
284
  // (3) enablePreciseTrimming is on, or (4) speed != 1.0. In those cases, -c copy
88
285
  // won't work because either we need video filters or we need frame-accurate cut points.
89
286
  val needsReEncode = hasUserTransform || cropNormalized != null || enablePreciseTrimming || speed != 1.0
90
287
 
91
- if (needsReEncode) {
92
- val videoFilters = mutableListOf<String>()
288
+ val callbacks = TrimCallbacks(
289
+ onLog = { callback.onLog(it) },
290
+ onStatistics = { callback.onStatistics(it) },
291
+ onProgress = { callback.onTrimmingProgress(it) },
292
+ onSuccess = { callback.onFinishTrim(outputFile, startMs, endMs, videoDuration) },
293
+ onCancel = { callback.onCancelTrim() },
294
+ onError = { msg, code -> callback.onError(msg, code) },
295
+ )
93
296
 
94
- when (userRotationCount) {
95
- 1 -> videoFilters.add("transpose=2")
96
- 2 -> { videoFilters.add("transpose=2"); videoFilters.add("transpose=2") }
97
- 3 -> videoFilters.add("transpose=1")
98
- }
99
- if (userIsFlipped) {
100
- videoFilters.add("hflip")
297
+ if (!needsReEncode) {
298
+ // Stream copy: no re-encoding, extremely fast but only cuts at keyframes.
299
+ // No encoder is opened so the fallback chain doesn't apply — single attempt.
300
+ val cmds = mutableListOf("-ss", "${startMs}ms", "-to", "${endMs}ms", "-i", inputFile)
301
+ if (removeAudio) {
302
+ cmds.addAll(listOf("-c:v", "copy", "-an"))
303
+ } else {
304
+ cmds.addAll(listOf("-c", "copy"))
101
305
  }
306
+ cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", outputFile))
307
+ return executeWithEncoderFallback(
308
+ encoderConfigs = listOf(emptyList()),
309
+ buildCommand = { cmds.toTypedArray() },
310
+ videoDurationMs = videoDuration,
311
+ callbacks = callbacks,
312
+ )
313
+ }
102
314
 
103
- // Convert normalized crop rect [0..1] to pixel coordinates in the post-rotation frame.
104
- if (cropNormalized != null && videoWidth > 0 && videoHeight > 0) {
105
- val postW: Int
106
- val postH: Int
107
- // After 90°/270° rotation the width and height are swapped.
108
- if (userRotationCount % 2 != 0) {
109
- postW = videoHeight; postH = videoWidth
110
- } else {
111
- postW = videoWidth; postH = videoHeight
112
- }
113
- val cx = (cropNormalized.left * postW).roundToInt()
114
- val cy = (cropNormalized.top * postH).roundToInt()
115
- var cw = (cropNormalized.width() * postW).roundToInt()
116
- var ch = (cropNormalized.height() * postH).roundToInt()
117
- // H.264 requires even dimensions; round down to nearest even number.
118
- cw = cw and 1.inv()
119
- ch = ch and 1.inv()
120
- if (cw > 0 && ch > 0) {
121
- videoFilters.add("crop=$cw:$ch:$cx:$cy")
122
- }
315
+ // Build the video filters once. They're encoder-independent.
316
+ val videoFilters = mutableListOf<String>()
317
+ when (userRotationCount) {
318
+ 1 -> videoFilters.add("transpose=2")
319
+ 2 -> { videoFilters.add("transpose=2"); videoFilters.add("transpose=2") }
320
+ 3 -> videoFilters.add("transpose=1")
321
+ }
322
+ if (userIsFlipped) {
323
+ videoFilters.add("hflip")
324
+ }
325
+ // Convert normalized crop rect [0..1] to pixel coordinates in the post-rotation frame.
326
+ if (cropNormalized != null && videoWidth > 0 && videoHeight > 0) {
327
+ val postW: Int
328
+ val postH: Int
329
+ // After 90°/270° rotation the width and height are swapped.
330
+ if (userRotationCount % 2 != 0) {
331
+ postW = videoHeight; postH = videoWidth
332
+ } else {
333
+ postW = videoWidth; postH = videoHeight
123
334
  }
124
-
125
- if (speed != 1.0) {
126
- videoFilters.add("setpts=${1.0 / speed}*PTS")
335
+ val cx = (cropNormalized.left * postW).roundToInt()
336
+ val cy = (cropNormalized.top * postH).roundToInt()
337
+ var cw = (cropNormalized.width() * postW).roundToInt()
338
+ var ch = (cropNormalized.height() * postH).roundToInt()
339
+ // H.264 requires even dimensions; round down to nearest even number.
340
+ cw = cw and 1.inv()
341
+ ch = ch and 1.inv()
342
+ if (cw > 0 && ch > 0) {
343
+ videoFilters.add("crop=$cw:$ch:$cx:$cy")
127
344
  }
345
+ }
346
+ if (speed != 1.0) {
347
+ videoFilters.add("setpts=${1.0 / speed}*PTS")
348
+ }
349
+ val filterString = videoFilters.joinToString(",")
128
350
 
129
- val filterString = videoFilters.joinToString(",")
130
- // Preserve source quality by matching the original bitrate. Falls back to 10 Mbps.
131
- val bitrateStr = if (videoBitrate > 0) "$videoBitrate" else "10M"
351
+ // Preserve source quality by matching the original bitrate. Falls back to 10 Mbps.
352
+ val bitrateStr = if (videoBitrate > 0) "$videoBitrate" else "10M"
132
353
 
133
- cmds.addAll(listOf("-i", inputFile))
354
+ // Note: Android FFmpegKit auto-rotates by default, so no -noautorotate is needed.
355
+ // The transpose filters above only handle user-initiated rotation, not source metadata.
356
+ val buildCommand: (List<String>) -> Array<String> = { encoderArgs ->
357
+ val cmds = mutableListOf("-ss", "${startMs}ms", "-to", "${endMs}ms", "-i", inputFile)
134
358
  // When enablePreciseTrimming is the only reason for re-encode (no transforms),
135
359
  // videoFilters is empty — skip -vf entirely to avoid FFmpeg error on empty filter.
136
360
  if (filterString.isNotEmpty()) {
137
361
  cmds.addAll(listOf("-vf", filterString))
138
362
  }
139
- // h264_mediacodec: Android's hardware H.264 encoder — fast and energy-efficient.
140
- // Note: Android FFmpegKit auto-rotates by default, so no -noautorotate is needed.
141
- // The transpose filters above only handle user-initiated rotation, not source metadata.
142
- cmds.addAll(listOf("-c:v", "h264_mediacodec", "-b:v", bitrateStr))
363
+ cmds.addAll(encoderArgs)
143
364
  when {
144
365
  removeAudio -> cmds.add("-an")
145
366
  speed != 1.0 -> cmds.addAll(listOf("-af", buildAtempoChain(speed)))
146
367
  else -> cmds.addAll(listOf("-c:a", "copy"))
147
368
  }
148
369
  cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", outputFile))
149
- } else {
150
- // Stream copy: no re-encoding, extremely fast but only cuts at keyframes.
151
- cmds.addAll(listOf("-i", inputFile))
152
- if (removeAudio) {
153
- cmds.addAll(listOf("-c:v", "copy", "-an"))
154
- } else {
155
- cmds.addAll(listOf("-c", "copy"))
156
- }
157
- cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", outputFile))
370
+ cmds.toTypedArray()
158
371
  }
159
372
 
160
- val command = cmds.toTypedArray()
161
- val cmdStr = "Command: ${command.joinToString(" ")}"
162
-
163
- Log.d(TAG, cmdStr)
164
-
165
- val m = Arguments.createMap()
166
- m.putString("message", cmdStr)
167
- callback.onLog(m)
168
-
169
- return FFmpegKit.executeWithArgumentsAsync(command, { session ->
170
- val state = session.state
171
- val returnCode = session.returnCode
172
- UiThreadUtil.runOnUiThread {
173
- when {
174
- ReturnCode.isSuccess(returnCode) -> {
175
- callback.onFinishTrim(outputFile, startMs, endMs, videoDuration)
176
- }
177
- ReturnCode.isCancel(returnCode) -> {
178
- callback.onCancelTrim()
179
- }
180
- else -> {
181
- val errorMessage = "Command failed with state $state and rc $returnCode.${session.failStackTrace}"
182
- callback.onError(errorMessage, ErrorCode.TRIMMING_FAILED)
183
- }
184
- }
185
- }
186
- }, { log ->
187
- Log.d(TAG, "FFmpeg process started with log ${log.message}")
188
-
189
- val map = Arguments.createMap()
190
- map.putInt("level", log.level.value)
191
- map.putString("message", log.message)
192
- map.putDouble("sessionId", log.sessionId.toDouble())
193
- map.putString("logStr", log.toString())
194
- UiThreadUtil.runOnUiThread {
195
- callback.onLog(map)
196
- }
197
- }, { statistics ->
198
- val timeInMilliseconds = statistics.time.toInt()
199
- if (timeInMilliseconds > 0) {
200
- val completePercentage = (timeInMilliseconds * 100) / videoDuration
201
- UiThreadUtil.runOnUiThread {
202
- callback.onTrimmingProgress(completePercentage.coerceIn(0, 100))
203
- }
204
- }
205
-
206
- val map = Arguments.createMap()
207
- map.putDouble("sessionId", statistics.sessionId.toDouble())
208
- map.putInt("videoFrameNumber", statistics.videoFrameNumber)
209
- map.putDouble("videoFps", statistics.videoFps.toDouble())
210
- map.putDouble("videoQuality", statistics.videoQuality.toDouble())
211
- map.putDouble("size", statistics.size.toDouble())
212
- map.putDouble("time", statistics.time)
213
- map.putDouble("bitrate", statistics.bitrate.toDouble())
214
- map.putDouble("speed", statistics.speed.toDouble())
215
- map.putString("statisticsStr", statistics.toString())
216
- UiThreadUtil.runOnUiThread {
217
- callback.onStatistics(map)
218
- }
219
- })
373
+ return executeWithEncoderFallback(
374
+ encoderConfigs = reEncodeEncoderConfigs(bitrateStr),
375
+ buildCommand = buildCommand,
376
+ videoDurationMs = videoDuration,
377
+ callbacks = callbacks,
378
+ )
220
379
  }
221
380
 
222
381
  fun shootVideoThumbInBackground(
@@ -39,7 +39,6 @@ import android.widget.TextView
39
39
 
40
40
  import androidx.appcompat.app.AlertDialog
41
41
 
42
- import com.arthenica.ffmpegkit.FFmpegSession
43
42
  import com.facebook.react.bridge.ReactApplicationContext
44
43
  import com.facebook.react.bridge.ReadableMap
45
44
  import com.facebook.react.bridge.UiThreadUtil.runOnUiThread
@@ -172,7 +171,7 @@ class VideoTrimmerView(
172
171
  private var jumpToPositionOnLoad = 0L
173
172
  private lateinit var headerView: FrameLayout
174
173
  private lateinit var headerText: TextView
175
- private var ffmpegSession: FFmpegSession? = null
174
+ private var trimSession: com.videotrim.utils.TrimSession? = null
176
175
  private var alertOnFailToLoad = true
177
176
  private var alertOnFailTitle = "Error"
178
177
  private var alertOnFailMessage = "Fail to load media. Possibly invalid file or no network connection"
@@ -692,7 +691,7 @@ class VideoTrimmerView(
692
691
  ?.toLongOrNull() ?: 0L
693
692
  }
694
693
  val effectiveRemoveAudio = isMuted || configRemoveAudio
695
- ffmpegSession = VideoTrimmerUtil.trim(
694
+ trimSession = VideoTrimmerUtil.trim(
696
695
  mSourceUri.toString(),
697
696
  StorageUtil.getOutputPath(mContext, mOutputExt),
698
697
  mDuration,
@@ -712,8 +711,8 @@ class VideoTrimmerView(
712
711
  }
713
712
 
714
713
  fun onCancelTrimClicked() {
715
- if (ffmpegSession != null) {
716
- ffmpegSession!!.cancel()
714
+ if (trimSession != null) {
715
+ trimSession!!.cancel()
717
716
  } else {
718
717
  mOnTrimVideoListener.onCancelTrim()
719
718
  }
@@ -9,6 +9,7 @@ import Foundation
9
9
 
10
10
  enum ErrorCode: String {
11
11
  case trimmingFailed = "TRIMMING_FAILED"
12
+ case hardwareEncoderFailed = "HARDWARE_ENCODER_FAILED"
12
13
  case failToLoadMedia = "FAIL_TO_LOAD_MEDIA"
13
14
  case failToSaveToPhoto = "FAIL_TO_SAVE_TO_PHOTO"
14
15
  case failToShare = "FAIL_TO_SHARE"
@@ -571,7 +571,8 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
571
571
  self.emitEventToJS("onCancelTrimming", eventData: nil)
572
572
  } else {
573
573
  // FAILURE
574
- self.onError(message: "Command failed with state \(String(describing: FFmpegKitConfig.sessionState(toString: state ?? .failed))) and rc \(String(describing: returnCode)).\(String(describing: session?.getFailStackTrace()))", code: .trimmingFailed)
574
+ let classified = VideoTrim.classifyFFmpegError(session: session)
575
+ self.onError(message: "Command failed with state \(String(describing: FFmpegKitConfig.sessionState(toString: state ?? .failed))) and rc \(String(describing: returnCode)).\(String(describing: session?.getFailStackTrace()))", code: classified)
575
576
  shouldCloseEditor = self.closeWhenFinish
576
577
  }
577
578
 
@@ -904,6 +905,32 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
904
905
  ]
905
906
  self.emitEventToJS("onError", eventData: eventPayload)
906
907
  }
908
+
909
+ /// Scan an FFmpeg session's log for signatures that indicate the hardware
910
+ /// video encoder (`h264_videotoolbox`) refused to configure on this device.
911
+ /// VideoToolbox failures are extremely rare on supported iOS hardware — Apple
912
+ /// controls the entire stack and regression-tests it — but the classifier
913
+ /// exists for API parity with Android (where the same hardware-encoder bug is
914
+ /// common and reproducible) and to give consumers a more actionable
915
+ /// `errorCode` than the generic `TRIMMING_FAILED` if it ever happens.
916
+ ///
917
+ /// Detected signals:
918
+ /// - `VTCompressionSession` errors
919
+ /// - `Error initializing output stream` / `Error while opening encoder`
920
+ /// combined with a `videotoolbox` mention in the same session log
921
+ static func classifyFFmpegError(session: FFmpegSession?) -> ErrorCode {
922
+ let logs = session?.getAllLogsAsString() ?? ""
923
+ let hardwareEncoderSignals = [
924
+ "VTCompressionSession",
925
+ "Error initializing output stream",
926
+ "Error while opening encoder",
927
+ ]
928
+ let matchedHardwareSignal = hardwareEncoderSignals.contains { logs.contains($0) }
929
+ if matchedHardwareSignal && logs.localizedCaseInsensitiveContains("videotoolbox") {
930
+ return .hardwareEncoderFailed
931
+ }
932
+ return .trimmingFailed
933
+ }
907
934
  }
908
935
 
909
936
  // MARK: @objc instance methods
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-video-trim",
3
- "version": "8.1.1",
3
+ "version": "8.1.2",
4
4
  "description": "Video trimmer for your React Native app",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",