react-native-video-trim 8.1.4 → 8.1.6

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
@@ -763,16 +763,32 @@ When any transform is applied, FFmpeg automatically re-encodes the video using t
763
763
 
764
764
  ### Android encoder compatibility (auto fallback)
765
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.
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, certain Samsung Galaxy models, and other older Qualcomm/MediaTek chipsets). This affects every code path that re-encodes video.
767
767
 
768
768
  The library handles this automatically with a two-step encoder fallback chain:
769
769
 
770
770
  1. `h264_mediacodec` — hardware H.264, fast, default. Used on every device first.
771
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
772
 
773
+ The fallback is wired into every Android API that opens a video encoder:
774
+
775
+ | API | Goes through fallback? |
776
+ |-----|------------------------|
777
+ | `showEditor` save (when transform / crop / `enablePreciseTrimming` / speed) | Yes |
778
+ | `trim` (when `enablePreciseTrimming` or `speed != 1.0`) | Yes |
779
+ | `trim` (plain — stream copy `-c copy`) | N/A, no encoder is opened |
780
+ | `compress` | Yes (always re-encodes) |
781
+ | `merge` | Yes (always re-encodes) |
782
+ | `extractAudio` | N/A, no video encoder (`-vn`) |
783
+ | `getFrameAt` | N/A, uses `MediaMetadataRetriever` |
784
+ | `toGif` | N/A, uses the GIF encoder, not `h264_mediacodec` |
785
+
773
786
  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
787
 
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.
788
+ If every attempt in the chain fails:
789
+
790
+ - **Editor save**: `onError` is emitted with `errorCode: "HARDWARE_ENCODER_FAILED"` (instead of the generic `"TRIMMING_FAILED"`).
791
+ - **Headless APIs (`trim` / `compress` / `merge`)**: the Promise rejects with the original error message format (`"Compression failed: rc N\n<logs>"` etc.) including the full FFmpeg log of the final attempt for debugging.
776
792
 
777
793
  > 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
794
 
@@ -68,7 +68,6 @@ open class BaseVideoTrimModule internal constructor(
68
68
  private val sendEvent: (eventName: String, params: WritableMap?) -> Unit
69
69
  ) : VideoTrimListener, LifecycleEventListener {
70
70
 
71
- private var isInit: Boolean = false
72
71
  private var trimmerView: VideoTrimmerView? = null
73
72
  private var alertDialog: AlertDialog? = null
74
73
  private var mProgressDialog: AlertDialog? = null
@@ -84,6 +83,10 @@ open class BaseVideoTrimModule internal constructor(
84
83
  private var pendingSaveToDocumentsFile: File? = null
85
84
 
86
85
  init {
86
+ // Initialize BaseUtils eagerly so DeviceUtil / VideoTrimmerUtil static
87
+ // fields resolve correctly even when a headless API (compress, toGif,
88
+ // merge, …) is called before showEditor has ever been opened.
89
+ BaseUtils.init(reactApplicationContext)
87
90
  reactApplicationContext.addLifecycleEventListener(this)
88
91
 
89
92
  val mActivityEventListener = object : BaseActivityEventListener() {
@@ -190,11 +193,6 @@ open class BaseVideoTrimModule internal constructor(
190
193
  onError("Activity is not available", ErrorCode.UNKNOWN)
191
194
  return
192
195
  }
193
- if (!isInit) {
194
- init()
195
- isInit = true
196
- }
197
-
198
196
  // here is NOT main thread, we need to create VideoTrimmerView on UI thread, so that later we can update it using same thread
199
197
  UiThreadUtil.runOnUiThread {
200
198
  trimmerView = VideoTrimmerView(reactApplicationContext, editorConfig, null)
@@ -290,12 +288,6 @@ open class BaseVideoTrimModule internal constructor(
290
288
  }
291
289
  }
292
290
 
293
- private fun init() {
294
- isInit = true
295
- // we have to init this before create videoTrimmerView
296
- BaseUtils.init(reactApplicationContext)
297
- }
298
-
299
291
  override fun onHostResume() {
300
292
  Log.d(TAG, "onHostResume: ")
301
293
  }
@@ -682,7 +674,7 @@ open class BaseVideoTrimModule internal constructor(
682
674
  println("FFmpeg command was cancelled")
683
675
  promise.reject(Exception("FFmpeg command was cancelled"))
684
676
  },
685
- onError = { errorMessage, _ ->
677
+ onError = { errorMessage, _, _ ->
686
678
  Log.d(TAG, errorMessage)
687
679
  promise.reject(Exception(errorMessage))
688
680
  },
@@ -741,7 +733,9 @@ open class BaseVideoTrimModule internal constructor(
741
733
  speed != 1.0 -> cmds.addAll(listOf("-af", VideoTrimmerUtil.buildAtempoChain(speed)))
742
734
  else -> cmds.addAll(listOf("-c:a", "copy"))
743
735
  }
744
- cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", resolvedOutputFile))
736
+ // Same high-tbr fix as compress: prevents duplicate DTS on sources like Pixel 7
737
+ // recordings (45k tbr) when re-encoding with h264_mediacodec.
738
+ cmds.addAll(listOf("-fps_mode", "vfr", "-metadata", "creation_time=$formattedDateTime", resolvedOutputFile))
745
739
  cmds.toTypedArray()
746
740
  }
747
741
 
@@ -863,6 +857,9 @@ open class BaseVideoTrimModule internal constructor(
863
857
  // Re-encodes video with h264_mediacodec (hardware) at the requested quality/bitrate.
864
858
  // Uses preset bitrate tiers for quality levels since Android's MediaCodec does not
865
859
  // support CRF-style quality control like VideoToolbox's -global_quality on iOS.
860
+ // Routed through VideoTrimmerUtil.executeWithEncoderFallback so the encoder
861
+ // fallback chain (h264_mediacodec → mpeg4) covers this path too — see README's
862
+ // "Android encoder compatibility" section.
866
863
  fun compress(url: String, options: ReadableMap?, promise: Promise) {
867
864
  val quality = options?.getString("quality") ?: "medium"
868
865
  val bitrate = options?.getDouble("bitrate") ?: -1.0
@@ -874,9 +871,7 @@ open class BaseVideoTrimModule internal constructor(
874
871
 
875
872
  val outputFile = StorageUtil.getCacheOutputPath(reactApplicationContext, outputExt)
876
873
 
877
- val cmds = mutableListOf("-i", url)
878
874
  val videoFilters = mutableListOf<String>()
879
-
880
875
  if (width > 0 && height > 0) {
881
876
  videoFilters.add("scale=$width:$height")
882
877
  } else if (width > 0) {
@@ -884,50 +879,61 @@ open class BaseVideoTrimModule internal constructor(
884
879
  } else if (height > 0) {
885
880
  videoFilters.add("scale=-2:$height")
886
881
  }
882
+ val filterString = videoFilters.joinToString(",")
887
883
 
888
- if (videoFilters.isNotEmpty()) {
889
- cmds.addAll(listOf("-vf", videoFilters.joinToString(",")))
884
+ val bitrateStr = if (bitrate > 0) "${bitrate.toLong()}" else when (quality) {
885
+ "low" -> "500K"
886
+ "high" -> "5M"
887
+ else -> "2M"
890
888
  }
891
889
 
892
- cmds.addAll(listOf("-c:v", "h264_mediacodec"))
893
-
894
- if (bitrate > 0) {
895
- cmds.addAll(listOf("-b:v", "${bitrate.toLong()}"))
896
- } else {
897
- val bv = when (quality) {
898
- "low" -> "500K"
899
- "high" -> "5M"
900
- else -> "2M"
890
+ val buildCommand: (List<String>) -> Array<String> = { encoderArgs ->
891
+ val cmds = mutableListOf("-i", url)
892
+ if (filterString.isNotEmpty()) {
893
+ cmds.addAll(listOf("-vf", filterString))
901
894
  }
902
- cmds.addAll(listOf("-b:v", bv))
903
- }
904
-
905
- if (frameRate > 0) {
906
- cmds.addAll(listOf("-r", "$frameRate"))
907
- }
908
-
909
- if (removeAudio) {
910
- cmds.add("-an")
911
- } else {
912
- cmds.addAll(listOf("-c:a", "aac"))
895
+ cmds.addAll(encoderArgs)
896
+ if (frameRate > 0) {
897
+ cmds.addAll(listOf("-r", "$frameRate"))
898
+ }
899
+ if (removeAudio) {
900
+ cmds.add("-an")
901
+ } else {
902
+ cmds.addAll(listOf("-c:a", "aac"))
903
+ }
904
+ // -fps_mode vfr prevents frame duplication / non-monotonic DTS errors that
905
+ // occur when the source has an unusually high time-base (e.g. Pixel 7 recordings
906
+ // with 45k tbr). Without this, h264_mediacodec tries to encode at the tbr rate,
907
+ // producing hundreds of duplicate frames and then a fatal muxer DTS collision.
908
+ cmds.addAll(listOf("-fps_mode", "vfr", "-y", outputFile))
909
+ cmds.toTypedArray()
913
910
  }
914
911
 
915
- cmds.addAll(listOf("-y", outputFile))
916
- Log.d(TAG, "compress command: ${cmds.joinToString(" ")}")
912
+ val callbacks = VideoTrimmerUtil.TrimCallbacks(
913
+ onLog = { msg -> msg.getString("message")?.let { Log.d(TAG, "compress: $it") } },
914
+ onStatistics = { /* compress does not surface statistics */ },
915
+ onProgress = { /* compress does not surface progress */ },
916
+ onSuccess = {
917
+ val result = Arguments.createMap()
918
+ result.putString("outputPath", outputFile)
919
+ promise.resolve(result)
920
+ },
921
+ onCancel = { promise.reject(Exception("Compression was cancelled")) },
922
+ onError = { _, _, session ->
923
+ // Preserve the original error message shape: "Compression failed: rc N\n<full logs>"
924
+ // so consumers matching on this prefix continue to work.
925
+ val returnCode = session?.returnCode
926
+ val logs = session?.allLogsAsString ?: ""
927
+ promise.reject(Exception("Compression failed: rc $returnCode\n$logs"))
928
+ },
929
+ )
917
930
 
918
- FFmpegKit.executeWithArgumentsAsync(cmds.toTypedArray(), { session ->
919
- val returnCode = session.returnCode
920
- UiThreadUtil.runOnUiThread {
921
- if (ReturnCode.isSuccess(returnCode)) {
922
- val result = Arguments.createMap()
923
- result.putString("outputPath", outputFile)
924
- promise.resolve(result)
925
- } else {
926
- val logs = session.allLogsAsString ?: ""
927
- promise.reject(Exception("Compression failed: rc $returnCode\n$logs"))
928
- }
929
- }
930
- }, null, null)
931
+ VideoTrimmerUtil.executeWithEncoderFallback(
932
+ encoderConfigs = VideoTrimmerUtil.reEncodeEncoderConfigs(bitrateStr),
933
+ buildCommand = buildCommand,
934
+ videoDurationMs = 0,
935
+ callbacks = callbacks,
936
+ )
931
937
  }
932
938
 
933
939
  // Two-pass GIF conversion: pass 1 generates an optimal color palette (palettegen),
@@ -999,6 +1005,10 @@ open class BaseVideoTrimModule internal constructor(
999
1005
  // detected bitrate as the output target so quality matches the best source clip.
1000
1006
  // Falls back to 10 Mbps if no bitrate can be read.
1001
1007
  //
1008
+ // Routed through VideoTrimmerUtil.executeWithEncoderFallback so the encoder
1009
+ // fallback chain (h264_mediacodec → mpeg4) covers this path too — see README's
1010
+ // "Android encoder compatibility" section.
1011
+ //
1002
1012
  // Limitation: only supports local file paths. Remote URLs are not supported because the
1003
1013
  // default FFmpegKit build does not include OpenSSL (--disable-openssl).
1004
1014
  fun merge(urls: ReadableArray, options: ReadableMap?, promise: Promise) {
@@ -1011,11 +1021,11 @@ open class BaseVideoTrimModule internal constructor(
1011
1021
  return
1012
1022
  }
1013
1023
 
1014
- val cmds = mutableListOf<String>()
1024
+ val inputArgs = mutableListOf<String>()
1015
1025
  var maxBitrate = 0L
1016
1026
  for (i in 0 until n) {
1017
1027
  val urlStr = urls.getString(i) ?: continue
1018
- cmds.addAll(listOf("-i", urlStr))
1028
+ inputArgs.addAll(listOf("-i", urlStr))
1019
1029
  try {
1020
1030
  val retriever = MediaMetadataRetriever()
1021
1031
  retriever.setDataSource(reactApplicationContext, Uri.parse(urlStr))
@@ -1048,38 +1058,55 @@ open class BaseVideoTrimModule internal constructor(
1048
1058
  val concatInputs = (0 until n).joinToString("") { "[v$it][$it:a:0]" }
1049
1059
  val filterComplex = "$scaleParts;${concatInputs}concat=n=$n:v=1:a=1[outv][outa]"
1050
1060
 
1051
- cmds.addAll(listOf(
1052
- "-filter_complex", filterComplex,
1053
- "-map", "[outv]", "-map", "[outa]",
1054
- "-c:v", "h264_mediacodec", "-b:v", bitrateStr,
1055
- "-c:a", "aac",
1056
- "-y", outputFile
1057
- ))
1058
- Log.d(TAG, "merge command: ${cmds.joinToString(" ")}")
1059
-
1060
- FFmpegKit.executeWithArgumentsAsync(cmds.toTypedArray(), { session ->
1061
- val returnCode = session.returnCode
1062
- UiThreadUtil.runOnUiThread {
1063
- if (ReturnCode.isSuccess(returnCode)) {
1064
- var duration = 0.0
1065
- try {
1066
- val retriever = MediaMetadataRetriever()
1067
- retriever.setDataSource(outputFile)
1068
- duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toDoubleOrNull() ?: 0.0
1069
- retriever.release()
1070
- } catch (_: Exception) {
1071
- }
1061
+ val buildCommand: (List<String>) -> Array<String> = { encoderArgs ->
1062
+ val cmds = mutableListOf<String>()
1063
+ cmds.addAll(inputArgs)
1064
+ cmds.addAll(listOf(
1065
+ "-filter_complex", filterComplex,
1066
+ "-map", "[outv]", "-map", "[outa]",
1067
+ ))
1068
+ cmds.addAll(encoderArgs)
1069
+ cmds.addAll(listOf(
1070
+ "-c:a", "aac",
1071
+ "-y", outputFile,
1072
+ ))
1073
+ cmds.toTypedArray()
1074
+ }
1072
1075
 
1073
- val result = Arguments.createMap()
1074
- result.putString("outputPath", outputFile)
1075
- result.putDouble("duration", duration)
1076
- promise.resolve(result)
1077
- } else {
1078
- val logs = session.allLogsAsString ?: ""
1079
- promise.reject(Exception("Merge failed: rc $returnCode\n$logs"))
1076
+ val callbacks = VideoTrimmerUtil.TrimCallbacks(
1077
+ onLog = { msg -> msg.getString("message")?.let { Log.d(TAG, "merge: $it") } },
1078
+ onStatistics = { /* merge does not surface statistics */ },
1079
+ onProgress = { /* merge does not surface progress */ },
1080
+ onSuccess = {
1081
+ var duration = 0.0
1082
+ try {
1083
+ val retriever = MediaMetadataRetriever()
1084
+ retriever.setDataSource(outputFile)
1085
+ duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toDoubleOrNull() ?: 0.0
1086
+ retriever.release()
1087
+ } catch (_: Exception) {
1080
1088
  }
1081
- }
1082
- }, null, null)
1089
+ val result = Arguments.createMap()
1090
+ result.putString("outputPath", outputFile)
1091
+ result.putDouble("duration", duration)
1092
+ promise.resolve(result)
1093
+ },
1094
+ onCancel = { promise.reject(Exception("Merge was cancelled")) },
1095
+ onError = { _, _, session ->
1096
+ // Preserve the original error message shape: "Merge failed: rc N\n<full logs>"
1097
+ // so consumers matching on this prefix continue to work.
1098
+ val returnCode = session?.returnCode
1099
+ val logs = session?.allLogsAsString ?: ""
1100
+ promise.reject(Exception("Merge failed: rc $returnCode\n$logs"))
1101
+ },
1102
+ )
1103
+
1104
+ VideoTrimmerUtil.executeWithEncoderFallback(
1105
+ encoderConfigs = VideoTrimmerUtil.reEncodeEncoderConfigs(bitrateStr),
1106
+ buildCommand = buildCommand,
1107
+ videoDurationMs = 0,
1108
+ callbacks = callbacks,
1109
+ )
1083
1110
  }
1084
1111
 
1085
1112
  private fun saveFileToExternalStorage(file: File) {
@@ -126,6 +126,13 @@ object VideoTrimmerUtil {
126
126
  * statistics are forwarded as-is; the success/cancel/error callbacks fire
127
127
  * exactly once for the overall trim request after the fallback chain
128
128
  * settles.
129
+ *
130
+ * The error callback receives a default message ("Command failed with
131
+ * state ... and rc ...") plus the final attempt's [FFmpegSession] so
132
+ * callers that surface custom error formats (e.g. headless APIs that
133
+ * include `allLogsAsString` in the Promise rejection) can build their
134
+ * own message instead. Pass `null`-safe access since the session may be
135
+ * unavailable in edge cases.
129
136
  */
130
137
  internal class TrimCallbacks(
131
138
  val onLog: (WritableMap) -> Unit,
@@ -133,7 +140,7 @@ object VideoTrimmerUtil {
133
140
  val onProgress: (Int) -> Unit,
134
141
  val onSuccess: () -> Unit,
135
142
  val onCancel: () -> Unit,
136
- val onError: (String, ErrorCode) -> Unit,
143
+ val onError: (message: String, code: ErrorCode, session: FFmpegSession?) -> Unit,
137
144
  )
138
145
 
139
146
  /**
@@ -178,7 +185,7 @@ object VideoTrimmerUtil {
178
185
  // Should be unreachable because we only retry on HARDWARE_ENCODER_FAILED;
179
186
  // any non-retryable error is surfaced before exhausting the iterator.
180
187
  UiThreadUtil.runOnUiThread {
181
- callbacks.onError("Encoder fallback chain exhausted", ErrorCode.TRIMMING_FAILED)
188
+ callbacks.onError("Encoder fallback chain exhausted", ErrorCode.TRIMMING_FAILED, null)
182
189
  }
183
190
  return
184
191
  }
@@ -213,7 +220,7 @@ object VideoTrimmerUtil {
213
220
  } else {
214
221
  val errorMessage =
215
222
  "Command failed with state $state and rc $returnCode.${session.failStackTrace}"
216
- callbacks.onError(errorMessage, classified)
223
+ callbacks.onError(errorMessage, classified, session)
217
224
  }
218
225
  }
219
226
  }
@@ -291,7 +298,7 @@ object VideoTrimmerUtil {
291
298
  onProgress = { callback.onTrimmingProgress(it) },
292
299
  onSuccess = { callback.onFinishTrim(outputFile, startMs, endMs, videoDuration) },
293
300
  onCancel = { callback.onCancelTrim() },
294
- onError = { msg, code -> callback.onError(msg, code) },
301
+ onError = { msg, code, _ -> callback.onError(msg, code) },
295
302
  )
296
303
 
297
304
  if (!needsReEncode) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-video-trim",
3
- "version": "8.1.4",
3
+ "version": "8.1.6",
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",