react-native-video-trim 8.1.4 → 8.1.5

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
 
@@ -682,7 +682,7 @@ open class BaseVideoTrimModule internal constructor(
682
682
  println("FFmpeg command was cancelled")
683
683
  promise.reject(Exception("FFmpeg command was cancelled"))
684
684
  },
685
- onError = { errorMessage, _ ->
685
+ onError = { errorMessage, _, _ ->
686
686
  Log.d(TAG, errorMessage)
687
687
  promise.reject(Exception(errorMessage))
688
688
  },
@@ -863,6 +863,9 @@ open class BaseVideoTrimModule internal constructor(
863
863
  // Re-encodes video with h264_mediacodec (hardware) at the requested quality/bitrate.
864
864
  // Uses preset bitrate tiers for quality levels since Android's MediaCodec does not
865
865
  // support CRF-style quality control like VideoToolbox's -global_quality on iOS.
866
+ // Routed through VideoTrimmerUtil.executeWithEncoderFallback so the encoder
867
+ // fallback chain (h264_mediacodec → mpeg4) covers this path too — see README's
868
+ // "Android encoder compatibility" section.
866
869
  fun compress(url: String, options: ReadableMap?, promise: Promise) {
867
870
  val quality = options?.getString("quality") ?: "medium"
868
871
  val bitrate = options?.getDouble("bitrate") ?: -1.0
@@ -874,9 +877,7 @@ open class BaseVideoTrimModule internal constructor(
874
877
 
875
878
  val outputFile = StorageUtil.getCacheOutputPath(reactApplicationContext, outputExt)
876
879
 
877
- val cmds = mutableListOf("-i", url)
878
880
  val videoFilters = mutableListOf<String>()
879
-
880
881
  if (width > 0 && height > 0) {
881
882
  videoFilters.add("scale=$width:$height")
882
883
  } else if (width > 0) {
@@ -884,50 +885,57 @@ open class BaseVideoTrimModule internal constructor(
884
885
  } else if (height > 0) {
885
886
  videoFilters.add("scale=-2:$height")
886
887
  }
888
+ val filterString = videoFilters.joinToString(",")
887
889
 
888
- if (videoFilters.isNotEmpty()) {
889
- cmds.addAll(listOf("-vf", videoFilters.joinToString(",")))
890
+ val bitrateStr = if (bitrate > 0) "${bitrate.toLong()}" else when (quality) {
891
+ "low" -> "500K"
892
+ "high" -> "5M"
893
+ else -> "2M"
890
894
  }
891
895
 
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"
896
+ val buildCommand: (List<String>) -> Array<String> = { encoderArgs ->
897
+ val cmds = mutableListOf("-i", url)
898
+ if (filterString.isNotEmpty()) {
899
+ cmds.addAll(listOf("-vf", filterString))
901
900
  }
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"))
901
+ cmds.addAll(encoderArgs)
902
+ if (frameRate > 0) {
903
+ cmds.addAll(listOf("-r", "$frameRate"))
904
+ }
905
+ if (removeAudio) {
906
+ cmds.add("-an")
907
+ } else {
908
+ cmds.addAll(listOf("-c:a", "aac"))
909
+ }
910
+ cmds.addAll(listOf("-y", outputFile))
911
+ cmds.toTypedArray()
913
912
  }
914
913
 
915
- cmds.addAll(listOf("-y", outputFile))
916
- Log.d(TAG, "compress command: ${cmds.joinToString(" ")}")
914
+ val callbacks = VideoTrimmerUtil.TrimCallbacks(
915
+ onLog = { msg -> msg.getString("message")?.let { Log.d(TAG, "compress: $it") } },
916
+ onStatistics = { /* compress does not surface statistics */ },
917
+ onProgress = { /* compress does not surface progress */ },
918
+ onSuccess = {
919
+ val result = Arguments.createMap()
920
+ result.putString("outputPath", outputFile)
921
+ promise.resolve(result)
922
+ },
923
+ onCancel = { promise.reject(Exception("Compression was cancelled")) },
924
+ onError = { _, _, session ->
925
+ // Preserve the original error message shape: "Compression failed: rc N\n<full logs>"
926
+ // so consumers matching on this prefix continue to work.
927
+ val returnCode = session?.returnCode
928
+ val logs = session?.allLogsAsString ?: ""
929
+ promise.reject(Exception("Compression failed: rc $returnCode\n$logs"))
930
+ },
931
+ )
917
932
 
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)
933
+ VideoTrimmerUtil.executeWithEncoderFallback(
934
+ encoderConfigs = VideoTrimmerUtil.reEncodeEncoderConfigs(bitrateStr),
935
+ buildCommand = buildCommand,
936
+ videoDurationMs = 0,
937
+ callbacks = callbacks,
938
+ )
931
939
  }
932
940
 
933
941
  // Two-pass GIF conversion: pass 1 generates an optimal color palette (palettegen),
@@ -999,6 +1007,10 @@ open class BaseVideoTrimModule internal constructor(
999
1007
  // detected bitrate as the output target so quality matches the best source clip.
1000
1008
  // Falls back to 10 Mbps if no bitrate can be read.
1001
1009
  //
1010
+ // Routed through VideoTrimmerUtil.executeWithEncoderFallback so the encoder
1011
+ // fallback chain (h264_mediacodec → mpeg4) covers this path too — see README's
1012
+ // "Android encoder compatibility" section.
1013
+ //
1002
1014
  // Limitation: only supports local file paths. Remote URLs are not supported because the
1003
1015
  // default FFmpegKit build does not include OpenSSL (--disable-openssl).
1004
1016
  fun merge(urls: ReadableArray, options: ReadableMap?, promise: Promise) {
@@ -1011,11 +1023,11 @@ open class BaseVideoTrimModule internal constructor(
1011
1023
  return
1012
1024
  }
1013
1025
 
1014
- val cmds = mutableListOf<String>()
1026
+ val inputArgs = mutableListOf<String>()
1015
1027
  var maxBitrate = 0L
1016
1028
  for (i in 0 until n) {
1017
1029
  val urlStr = urls.getString(i) ?: continue
1018
- cmds.addAll(listOf("-i", urlStr))
1030
+ inputArgs.addAll(listOf("-i", urlStr))
1019
1031
  try {
1020
1032
  val retriever = MediaMetadataRetriever()
1021
1033
  retriever.setDataSource(reactApplicationContext, Uri.parse(urlStr))
@@ -1048,38 +1060,55 @@ open class BaseVideoTrimModule internal constructor(
1048
1060
  val concatInputs = (0 until n).joinToString("") { "[v$it][$it:a:0]" }
1049
1061
  val filterComplex = "$scaleParts;${concatInputs}concat=n=$n:v=1:a=1[outv][outa]"
1050
1062
 
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
- }
1063
+ val buildCommand: (List<String>) -> Array<String> = { encoderArgs ->
1064
+ val cmds = mutableListOf<String>()
1065
+ cmds.addAll(inputArgs)
1066
+ cmds.addAll(listOf(
1067
+ "-filter_complex", filterComplex,
1068
+ "-map", "[outv]", "-map", "[outa]",
1069
+ ))
1070
+ cmds.addAll(encoderArgs)
1071
+ cmds.addAll(listOf(
1072
+ "-c:a", "aac",
1073
+ "-y", outputFile,
1074
+ ))
1075
+ cmds.toTypedArray()
1076
+ }
1072
1077
 
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"))
1078
+ val callbacks = VideoTrimmerUtil.TrimCallbacks(
1079
+ onLog = { msg -> msg.getString("message")?.let { Log.d(TAG, "merge: $it") } },
1080
+ onStatistics = { /* merge does not surface statistics */ },
1081
+ onProgress = { /* merge does not surface progress */ },
1082
+ onSuccess = {
1083
+ var duration = 0.0
1084
+ try {
1085
+ val retriever = MediaMetadataRetriever()
1086
+ retriever.setDataSource(outputFile)
1087
+ duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toDoubleOrNull() ?: 0.0
1088
+ retriever.release()
1089
+ } catch (_: Exception) {
1080
1090
  }
1081
- }
1082
- }, null, null)
1091
+ val result = Arguments.createMap()
1092
+ result.putString("outputPath", outputFile)
1093
+ result.putDouble("duration", duration)
1094
+ promise.resolve(result)
1095
+ },
1096
+ onCancel = { promise.reject(Exception("Merge was cancelled")) },
1097
+ onError = { _, _, session ->
1098
+ // Preserve the original error message shape: "Merge failed: rc N\n<full logs>"
1099
+ // so consumers matching on this prefix continue to work.
1100
+ val returnCode = session?.returnCode
1101
+ val logs = session?.allLogsAsString ?: ""
1102
+ promise.reject(Exception("Merge failed: rc $returnCode\n$logs"))
1103
+ },
1104
+ )
1105
+
1106
+ VideoTrimmerUtil.executeWithEncoderFallback(
1107
+ encoderConfigs = VideoTrimmerUtil.reEncodeEncoderConfigs(bitrateStr),
1108
+ buildCommand = buildCommand,
1109
+ videoDurationMs = 0,
1110
+ callbacks = callbacks,
1111
+ )
1083
1112
  }
1084
1113
 
1085
1114
  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.5",
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",