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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
889
|
-
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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(
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
916
|
-
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|
-
|
|
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) {
|