react-native-video-trim 8.1.3 → 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
|
|
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
|
|
|
@@ -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 (
|
|
889
|
-
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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(
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
cmds.
|
|
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
|
-
|
|
916
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|
-
|
|
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/ios/VideoTrim.swift
CHANGED
|
@@ -382,22 +382,13 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
|
|
|
382
382
|
let needsReEncode = hasUserTransform || cropNorm != nil || enablePreciseTrimming || needsSpeed
|
|
383
383
|
|
|
384
384
|
if needsReEncode, let vc = vc {
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
videoFilters.append("transpose=1")
|
|
393
|
-
} else if abs(sourceAngle + .pi / 2) < 0.1 {
|
|
394
|
-
videoFilters.append("transpose=2")
|
|
395
|
-
} else if abs(abs(sourceAngle) - .pi) < 0.1 {
|
|
396
|
-
videoFilters.append("transpose=1")
|
|
397
|
-
videoFilters.append("transpose=1")
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
385
|
+
// Let FFmpeg autorotate the source (note: NO -noautorotate below). Autorotate bakes
|
|
386
|
+
// the source rotation matrix into upright, display-orientation pixels AND strips the
|
|
387
|
+
// matrix from the output. The previous approach (-noautorotate + a manual source-
|
|
388
|
+
// compensation transpose) baked the pixels but left the source rotation matrix on the
|
|
389
|
+
// output, so on ffmpeg-kit 6 every rotation-tagged portrait clip came out
|
|
390
|
+
// double-rotated (sideways). User rotate/flip and crop below operate on the already-
|
|
391
|
+
// upright autorotated frame, so their math is unchanged.
|
|
401
392
|
switch vc.rotationCount {
|
|
402
393
|
case 1: videoFilters.append("transpose=2")
|
|
403
394
|
case 2:
|
|
@@ -464,9 +455,9 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
|
|
|
464
455
|
}
|
|
465
456
|
}
|
|
466
457
|
|
|
467
|
-
// -noautorotate
|
|
468
|
-
//
|
|
469
|
-
|
|
458
|
+
// NOTE: intentionally NO -noautorotate. FFmpeg autorotates the input so the source
|
|
459
|
+
// rotation is baked into the pixels and the output carries no stale rotation matrix
|
|
460
|
+
// (otherwise rotation-tagged portrait clips are double-rotated -> sideways on ffmpeg-kit 6).
|
|
470
461
|
cmds.append(contentsOf: ["-i", inputFile.path])
|
|
471
462
|
// When enablePreciseTrimming is the only reason for re-encode (no transforms),
|
|
472
463
|
// videoFilters is empty — skip -vf entirely to avoid FFmpeg error on empty filter.
|