react-native-video-trim 8.1.1 → 8.1.3

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,123 @@ 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
+ // -y overwrites any pre-existing output file without prompting.
694
+ val cmds = mutableListOf(
695
+ "-y",
696
+ "-ss", "${startTime}ms",
697
+ "-to", "${endTime}ms",
698
+ "-i", url,
699
+ )
700
+ if (removeAudio) {
701
+ cmds.addAll(listOf("-c:v", "copy", "-an"))
702
+ } else {
703
+ cmds.addAll(listOf("-c", "copy"))
704
+ }
705
+ cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", resolvedOutputFile))
706
+ VideoTrimmerUtil.executeWithEncoderFallback(
707
+ encoderConfigs = listOf(emptyList()),
708
+ buildCommand = { cmds.toTypedArray() },
709
+ videoDurationMs = 0,
710
+ callbacks = callbacks,
711
+ )
712
+ return
713
+ }
714
+
715
+ var bitrateStr = "10M"
716
+ try {
717
+ val retriever = MediaMetadataRetriever()
718
+ retriever.setDataSource(reactApplicationContext, Uri.parse(url))
719
+ val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
720
+ ?.toLongOrNull() ?: 0L
721
+ if (bitrate > 0) bitrateStr = "$bitrate"
722
+ retriever.release()
723
+ } catch (_: Exception) {}
654
724
 
655
- cmds.addAll(listOf("-i", url))
725
+ val buildCommand: (List<String>) -> Array<String> = { encoderArgs ->
726
+ // -y overwrites the output file without prompting, so the encoder fallback
727
+ // chain's software retry can reuse the same output path left behind by the
728
+ // failed hardware attempt instead of aborting on FFmpeg's overwrite prompt.
729
+ val cmds = mutableListOf(
730
+ "-y",
731
+ "-ss", "${startTime}ms",
732
+ "-to", "${endTime}ms",
733
+ "-i", url,
734
+ )
656
735
  if (speed != 1.0) {
657
736
  cmds.addAll(listOf("-vf", "setpts=${1.0 / speed}*PTS"))
658
737
  }
659
- cmds.addAll(listOf("-c:v", "h264_mediacodec", "-b:v", bitrateStr))
738
+ cmds.addAll(encoderArgs)
660
739
  when {
661
740
  removeAudio -> cmds.add("-an")
662
741
  speed != 1.0 -> cmds.addAll(listOf("-af", VideoTrimmerUtil.buildAtempoChain(speed)))
663
742
  else -> cmds.addAll(listOf("-c:a", "copy"))
664
743
  }
665
744
  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))
745
+ cmds.toTypedArray()
674
746
  }
675
747
 
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
- })
748
+ VideoTrimmerUtil.executeWithEncoderFallback(
749
+ encoderConfigs = VideoTrimmerUtil.reEncodeEncoderConfigs(bitrateStr),
750
+ buildCommand = buildCommand,
751
+ videoDurationMs = 0,
752
+ callbacks = callbacks,
753
+ )
742
754
  }
743
755
 
744
756
  // 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,115 @@ 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
+ // -y overwrites any pre-existing output file without prompting.
301
+ val cmds = mutableListOf("-y", "-ss", "${startMs}ms", "-to", "${endMs}ms", "-i", inputFile)
302
+ if (removeAudio) {
303
+ cmds.addAll(listOf("-c:v", "copy", "-an"))
304
+ } else {
305
+ cmds.addAll(listOf("-c", "copy"))
101
306
  }
307
+ cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", outputFile))
308
+ return executeWithEncoderFallback(
309
+ encoderConfigs = listOf(emptyList()),
310
+ buildCommand = { cmds.toTypedArray() },
311
+ videoDurationMs = videoDuration,
312
+ callbacks = callbacks,
313
+ )
314
+ }
102
315
 
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
- }
316
+ // Build the video filters once. They're encoder-independent.
317
+ val videoFilters = mutableListOf<String>()
318
+ when (userRotationCount) {
319
+ 1 -> videoFilters.add("transpose=2")
320
+ 2 -> { videoFilters.add("transpose=2"); videoFilters.add("transpose=2") }
321
+ 3 -> videoFilters.add("transpose=1")
322
+ }
323
+ if (userIsFlipped) {
324
+ videoFilters.add("hflip")
325
+ }
326
+ // Convert normalized crop rect [0..1] to pixel coordinates in the post-rotation frame.
327
+ if (cropNormalized != null && videoWidth > 0 && videoHeight > 0) {
328
+ val postW: Int
329
+ val postH: Int
330
+ // After 90°/270° rotation the width and height are swapped.
331
+ if (userRotationCount % 2 != 0) {
332
+ postW = videoHeight; postH = videoWidth
333
+ } else {
334
+ postW = videoWidth; postH = videoHeight
123
335
  }
124
-
125
- if (speed != 1.0) {
126
- videoFilters.add("setpts=${1.0 / speed}*PTS")
336
+ val cx = (cropNormalized.left * postW).roundToInt()
337
+ val cy = (cropNormalized.top * postH).roundToInt()
338
+ var cw = (cropNormalized.width() * postW).roundToInt()
339
+ var ch = (cropNormalized.height() * postH).roundToInt()
340
+ // H.264 requires even dimensions; round down to nearest even number.
341
+ cw = cw and 1.inv()
342
+ ch = ch and 1.inv()
343
+ if (cw > 0 && ch > 0) {
344
+ videoFilters.add("crop=$cw:$ch:$cx:$cy")
127
345
  }
346
+ }
347
+ if (speed != 1.0) {
348
+ videoFilters.add("setpts=${1.0 / speed}*PTS")
349
+ }
350
+ val filterString = videoFilters.joinToString(",")
128
351
 
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"
352
+ // Preserve source quality by matching the original bitrate. Falls back to 10 Mbps.
353
+ val bitrateStr = if (videoBitrate > 0) "$videoBitrate" else "10M"
132
354
 
133
- cmds.addAll(listOf("-i", inputFile))
355
+ // Note: Android FFmpegKit auto-rotates by default, so no -noautorotate is needed.
356
+ // The transpose filters above only handle user-initiated rotation, not source metadata.
357
+ val buildCommand: (List<String>) -> Array<String> = { encoderArgs ->
358
+ // -y overwrites the output file without prompting. This is critical for the
359
+ // encoder fallback chain: the first (hardware) attempt opens/creates the output
360
+ // file before MediaCodec fails, so the software retry reuses the same path and
361
+ // would otherwise hit FFmpeg's interactive "Overwrite? [y/N]" prompt and abort.
362
+ val cmds = mutableListOf("-y", "-ss", "${startMs}ms", "-to", "${endMs}ms", "-i", inputFile)
134
363
  // When enablePreciseTrimming is the only reason for re-encode (no transforms),
135
364
  // videoFilters is empty — skip -vf entirely to avoid FFmpeg error on empty filter.
136
365
  if (filterString.isNotEmpty()) {
137
366
  cmds.addAll(listOf("-vf", filterString))
138
367
  }
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))
368
+ cmds.addAll(encoderArgs)
143
369
  when {
144
370
  removeAudio -> cmds.add("-an")
145
371
  speed != 1.0 -> cmds.addAll(listOf("-af", buildAtempoChain(speed)))
146
372
  else -> cmds.addAll(listOf("-c:a", "copy"))
147
373
  }
148
374
  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))
375
+ cmds.toTypedArray()
158
376
  }
159
377
 
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
- })
378
+ return executeWithEncoderFallback(
379
+ encoderConfigs = reEncodeEncoderConfigs(bitrateStr),
380
+ buildCommand = buildCommand,
381
+ videoDurationMs = videoDuration,
382
+ callbacks = callbacks,
383
+ )
220
384
  }
221
385
 
222
386
  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"
@@ -359,7 +359,11 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
359
359
  root.present(progressAlert, animated: true, completion: nil)
360
360
  }
361
361
 
362
+ // -y overwrites any pre-existing output file without prompting, so FFmpeg never
363
+ // blocks on an interactive "Overwrite? [y/N]" prompt (hardening; also keeps the
364
+ // command symmetric with Android, where -y is required by the encoder fallback chain).
362
365
  var cmds = [
366
+ "-y",
363
367
  "-ss",
364
368
  "\(startTime * 1000)ms",
365
369
  "-to",
@@ -571,7 +575,8 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
571
575
  self.emitEventToJS("onCancelTrimming", eventData: nil)
572
576
  } else {
573
577
  // 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)
578
+ let classified = VideoTrim.classifyFFmpegError(session: session)
579
+ 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
580
  shouldCloseEditor = self.closeWhenFinish
576
581
  }
577
582
 
@@ -647,7 +652,9 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
647
652
 
648
653
  let startTime = config["startTime"] as? Double ?? 0
649
654
  let endTime = config["endTime"] as? Double ?? 0
655
+ // -y overwrites any pre-existing output file without prompting (hardening).
650
656
  var cmds = [
657
+ "-y",
651
658
  "-ss",
652
659
  "\(startTime)ms",
653
660
  "-to",
@@ -904,6 +911,32 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
904
911
  ]
905
912
  self.emitEventToJS("onError", eventData: eventPayload)
906
913
  }
914
+
915
+ /// Scan an FFmpeg session's log for signatures that indicate the hardware
916
+ /// video encoder (`h264_videotoolbox`) refused to configure on this device.
917
+ /// VideoToolbox failures are extremely rare on supported iOS hardware — Apple
918
+ /// controls the entire stack and regression-tests it — but the classifier
919
+ /// exists for API parity with Android (where the same hardware-encoder bug is
920
+ /// common and reproducible) and to give consumers a more actionable
921
+ /// `errorCode` than the generic `TRIMMING_FAILED` if it ever happens.
922
+ ///
923
+ /// Detected signals:
924
+ /// - `VTCompressionSession` errors
925
+ /// - `Error initializing output stream` / `Error while opening encoder`
926
+ /// combined with a `videotoolbox` mention in the same session log
927
+ static func classifyFFmpegError(session: FFmpegSession?) -> ErrorCode {
928
+ let logs = session?.getAllLogsAsString() ?? ""
929
+ let hardwareEncoderSignals = [
930
+ "VTCompressionSession",
931
+ "Error initializing output stream",
932
+ "Error while opening encoder",
933
+ ]
934
+ let matchedHardwareSignal = hardwareEncoderSignals.contains { logs.contains($0) }
935
+ if matchedHardwareSignal && logs.localizedCaseInsensitiveContains("videotoolbox") {
936
+ return .hardwareEncoderFailed
937
+ }
938
+ return .trimmingFailed
939
+ }
907
940
  }
908
941
 
909
942
  // 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.3",
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",