react-native-video-trim 7.1.1 → 8.1.0
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 +257 -1
- package/android/src/main/java/com/videotrim/BaseVideoTrimModule.kt +488 -34
- package/android/src/main/java/com/videotrim/utils/StorageUtil.kt +95 -36
- package/android/src/main/java/com/videotrim/utils/VideoTrimmerUtil.kt +38 -16
- package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.kt +92 -5
- package/android/src/main/res/drawable/speaker_slash_fill.xml +19 -0
- package/android/src/main/res/drawable/speaker_wave_2_fill.xml +23 -0
- package/android/src/main/res/layout/video_trimmer_view.xml +25 -3
- package/android/src/newarch/VideoTrimModule.kt +33 -0
- package/android/src/oldarch/VideoTrimModule.kt +41 -0
- package/android/src/oldarch/VideoTrimSpec.kt +17 -0
- package/ios/VideoTrim.mm +160 -1
- package/ios/VideoTrim.swift +632 -39
- package/ios/VideoTrimmerViewController.swift +129 -28
- package/lib/module/NativeVideoTrim.js +52 -0
- package/lib/module/NativeVideoTrim.js.map +1 -1
- package/lib/module/index.js +143 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/NativeVideoTrim.d.ts +161 -0
- package/lib/typescript/src/NativeVideoTrim.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +62 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/NativeVideoTrim.ts +183 -0
- package/src/index.tsx +186 -0
|
@@ -10,6 +10,7 @@ import android.content.DialogInterface
|
|
|
10
10
|
import android.content.Intent
|
|
11
11
|
import android.content.pm.PackageManager
|
|
12
12
|
import android.content.res.ColorStateList
|
|
13
|
+
import android.graphics.Bitmap
|
|
13
14
|
import android.graphics.Color
|
|
14
15
|
import android.media.MediaMetadataRetriever
|
|
15
16
|
import android.net.Uri
|
|
@@ -38,6 +39,7 @@ import com.facebook.react.bridge.BaseActivityEventListener
|
|
|
38
39
|
import com.facebook.react.bridge.LifecycleEventListener
|
|
39
40
|
import com.facebook.react.bridge.Promise
|
|
40
41
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
42
|
+
import com.facebook.react.bridge.ReadableArray
|
|
41
43
|
import com.facebook.react.bridge.ReadableMap
|
|
42
44
|
import com.facebook.react.bridge.UiThreadUtil
|
|
43
45
|
import com.facebook.react.bridge.WritableMap
|
|
@@ -45,14 +47,17 @@ import com.videotrim.enums.ErrorCode
|
|
|
45
47
|
import com.videotrim.interfaces.VideoTrimListener
|
|
46
48
|
import com.videotrim.utils.MediaMetadataUtil
|
|
47
49
|
import com.videotrim.utils.StorageUtil
|
|
50
|
+
import com.videotrim.utils.VideoTrimmerUtil
|
|
48
51
|
import com.videotrim.widgets.VideoTrimmerView
|
|
49
52
|
import iknow.android.utils.BaseUtils
|
|
50
53
|
import java.io.File
|
|
51
54
|
import java.io.FileInputStream
|
|
55
|
+
import java.io.FileOutputStream
|
|
52
56
|
import java.io.IOException
|
|
53
57
|
import java.text.SimpleDateFormat
|
|
54
58
|
import java.util.Date
|
|
55
59
|
import java.util.TimeZone
|
|
60
|
+
import kotlin.math.min
|
|
56
61
|
|
|
57
62
|
/**
|
|
58
63
|
* Contains all shared business logic between old + new arch.
|
|
@@ -75,6 +80,8 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
75
80
|
private var originalStatusBarColor: Int = Color.TRANSPARENT
|
|
76
81
|
private val shouldChangeStatusBarColorOnOpen: Boolean
|
|
77
82
|
get() = editorConfig?.hasKey("changeStatusBarColorOnOpen") == true && editorConfig?.getBoolean("changeStatusBarColorOnOpen") == true
|
|
83
|
+
private var pendingSaveToDocumentsPromise: Promise? = null
|
|
84
|
+
private var pendingSaveToDocumentsFile: File? = null
|
|
78
85
|
|
|
79
86
|
init {
|
|
80
87
|
reactApplicationContext.addLifecycleEventListener(this)
|
|
@@ -123,6 +130,44 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
123
130
|
hideDialog(true)
|
|
124
131
|
}
|
|
125
132
|
}
|
|
133
|
+
|
|
134
|
+
if (requestCode == REQUEST_CODE_SAVE_TO_DOCUMENTS) {
|
|
135
|
+
val promise = pendingSaveToDocumentsPromise
|
|
136
|
+
val sourceFile = pendingSaveToDocumentsFile
|
|
137
|
+
pendingSaveToDocumentsPromise = null
|
|
138
|
+
pendingSaveToDocumentsFile = null
|
|
139
|
+
|
|
140
|
+
if (promise == null || sourceFile == null) return
|
|
141
|
+
|
|
142
|
+
if (resultCode == Activity.RESULT_OK) {
|
|
143
|
+
val uri = intent?.data
|
|
144
|
+
if (uri == null) {
|
|
145
|
+
promise.reject(Exception("No destination selected"))
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
reactApplicationContext.contentResolver?.openOutputStream(uri)
|
|
150
|
+
?.use { outputStream ->
|
|
151
|
+
FileInputStream(sourceFile).use { inputStream ->
|
|
152
|
+
val buffer = ByteArray(1024)
|
|
153
|
+
var length: Int
|
|
154
|
+
while (inputStream.read(buffer).also { length = it } > 0) {
|
|
155
|
+
outputStream.write(buffer, 0, length)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
val result = Arguments.createMap()
|
|
160
|
+
result.putBoolean("success", true)
|
|
161
|
+
promise.resolve(result)
|
|
162
|
+
} catch (e: Exception) {
|
|
163
|
+
promise.reject(Exception("Failed to save to documents: ${e.message}"))
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
val result = Arguments.createMap()
|
|
167
|
+
result.putBoolean("success", false)
|
|
168
|
+
promise.resolve(result)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
126
171
|
}
|
|
127
172
|
}
|
|
128
173
|
reactApplicationContext.addActivityEventListener(mActivityEventListener)
|
|
@@ -298,7 +343,7 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
298
343
|
|
|
299
344
|
if (editorConfig?.getBoolean("saveToPhoto") == true && isVideoType) {
|
|
300
345
|
try {
|
|
301
|
-
StorageUtil.
|
|
346
|
+
StorageUtil.saveToGallery(reactApplicationContext, outputFile)
|
|
302
347
|
Log.d(TAG, "Edited video saved to Photo Library successfully.")
|
|
303
348
|
if (editorConfig?.getBoolean("removeAfterSavedToPhoto") == true) {
|
|
304
349
|
StorageUtil.deleteFile(outputFile)
|
|
@@ -576,27 +621,27 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
576
621
|
val startTime = options?.getDouble("startTime") ?: 0.0
|
|
577
622
|
val endTime = options?.getDouble("endTime") ?: 1000.0
|
|
578
623
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
"${startTime}ms",
|
|
582
|
-
"-to",
|
|
583
|
-
"${endTime}ms",
|
|
584
|
-
)
|
|
624
|
+
val removeAudio = options?.hasKey("removeAudio") == true && options.getBoolean("removeAudio")
|
|
625
|
+
val speed = if (options?.hasKey("speed") == true) options.getDouble("speed") else 1.0
|
|
585
626
|
|
|
586
627
|
val outputFile = StorageUtil.getOutputPath(reactApplicationContext, options?.getString("outputExt") ?: "mp4")
|
|
587
628
|
|
|
588
|
-
val resolvedOutputFile = outputFile
|
|
589
|
-
promise.reject(Exception("Failed to create output file path"))
|
|
590
|
-
return
|
|
591
|
-
}
|
|
629
|
+
val resolvedOutputFile = outputFile
|
|
592
630
|
|
|
593
631
|
// Headless trim: no editor UI, so no transforms (flip/rotate/crop) are possible.
|
|
594
|
-
//
|
|
632
|
+
// Re-encode for enablePreciseTrimming (frame-accurate cuts) or non-1.0 speed (filters + atempo).
|
|
595
633
|
val enablePrecise = options?.hasKey("enablePreciseTrimming") == true &&
|
|
596
634
|
options.getBoolean("enablePreciseTrimming")
|
|
635
|
+
val needsReEncode = enablePrecise || speed != 1.0
|
|
597
636
|
|
|
598
|
-
|
|
599
|
-
|
|
637
|
+
val cmds = mutableListOf(
|
|
638
|
+
"-ss",
|
|
639
|
+
"${startTime}ms",
|
|
640
|
+
"-to",
|
|
641
|
+
"${endTime}ms",
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
if (needsReEncode) {
|
|
600
645
|
var bitrateStr = "10M"
|
|
601
646
|
try {
|
|
602
647
|
val retriever = MediaMetadataRetriever()
|
|
@@ -607,29 +652,30 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
607
652
|
retriever.release()
|
|
608
653
|
} catch (_: Exception) {}
|
|
609
654
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
"-
|
|
617
|
-
"-
|
|
618
|
-
|
|
619
|
-
|
|
655
|
+
cmds.addAll(listOf("-i", url))
|
|
656
|
+
if (speed != 1.0) {
|
|
657
|
+
cmds.addAll(listOf("-vf", "setpts=${1.0 / speed}*PTS"))
|
|
658
|
+
}
|
|
659
|
+
cmds.addAll(listOf("-c:v", "h264_mediacodec", "-b:v", bitrateStr))
|
|
660
|
+
when {
|
|
661
|
+
removeAudio -> cmds.add("-an")
|
|
662
|
+
speed != 1.0 -> cmds.addAll(listOf("-af", VideoTrimmerUtil.buildAtempoChain(speed)))
|
|
663
|
+
else -> cmds.addAll(listOf("-c:a", "copy"))
|
|
664
|
+
}
|
|
665
|
+
cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", resolvedOutputFile))
|
|
620
666
|
} else {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
"-
|
|
624
|
-
|
|
625
|
-
"-
|
|
626
|
-
|
|
627
|
-
)
|
|
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))
|
|
628
674
|
}
|
|
629
675
|
|
|
630
|
-
Log.d(TAG, "Command: ${cmds.joinToString("
|
|
676
|
+
Log.d(TAG, "Command: ${cmds.joinToString(" ")}")
|
|
631
677
|
|
|
632
|
-
FFmpegKit.executeWithArgumentsAsync(cmds, { session ->
|
|
678
|
+
FFmpegKit.executeWithArgumentsAsync(cmds.toTypedArray(), { session ->
|
|
633
679
|
val state = session.state
|
|
634
680
|
val returnCode = session.returnCode
|
|
635
681
|
UiThreadUtil.runOnUiThread {
|
|
@@ -647,7 +693,7 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
647
693
|
if (options?.getBoolean("saveToPhoto") == true && options.getString("type") == "video") {
|
|
648
694
|
Log.d(TAG, "Android trim: saveToPhoto is true, attempting to save to gallery")
|
|
649
695
|
try {
|
|
650
|
-
StorageUtil.
|
|
696
|
+
StorageUtil.saveToGallery(reactApplicationContext, resolvedOutputFile)
|
|
651
697
|
Log.d(TAG, "Edited video saved to Photo Library successfully.")
|
|
652
698
|
if (options.getBoolean("removeAfterSavedToPhoto")) {
|
|
653
699
|
Log.d(TAG, "Removing file after successful save to photo")
|
|
@@ -695,6 +741,335 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
695
741
|
})
|
|
696
742
|
}
|
|
697
743
|
|
|
744
|
+
// Extracts a single video frame as JPEG/PNG. Output goes to the cache directory.
|
|
745
|
+
// On API 27+ (O_MR1), uses getScaledFrameAtTime with the video's native dimensions
|
|
746
|
+
// to get full-resolution frames. The plain getFrameAtTime() on older APIs may return
|
|
747
|
+
// a reduced-resolution bitmap at the decoder's discretion.
|
|
748
|
+
fun getFrameAt(url: String, options: ReadableMap?, promise: Promise) {
|
|
749
|
+
Thread {
|
|
750
|
+
try {
|
|
751
|
+
val time = options?.getDouble("time")?.toLong() ?: 0L
|
|
752
|
+
val format = options?.getString("format") ?: "jpeg"
|
|
753
|
+
val quality = options?.getInt("quality") ?: 80
|
|
754
|
+
val maxWidth = options?.getInt("maxWidth") ?: -1
|
|
755
|
+
val maxHeight = options?.getInt("maxHeight") ?: -1
|
|
756
|
+
|
|
757
|
+
val retriever = MediaMetadataUtil.getMediaMetadataRetriever(url)
|
|
758
|
+
if (retriever == null) {
|
|
759
|
+
UiThreadUtil.runOnUiThread { promise.reject(Exception("Failed to load media")) }
|
|
760
|
+
return@Thread
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
val videoWidth = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
|
|
764
|
+
val videoHeight = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0
|
|
765
|
+
|
|
766
|
+
var bitmap: Bitmap? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 && videoWidth > 0 && videoHeight > 0) {
|
|
767
|
+
retriever.getScaledFrameAtTime(time * 1000, MediaMetadataRetriever.OPTION_CLOSEST, videoWidth, videoHeight)
|
|
768
|
+
} else {
|
|
769
|
+
retriever.getFrameAtTime(time * 1000, MediaMetadataRetriever.OPTION_CLOSEST)
|
|
770
|
+
}
|
|
771
|
+
retriever.release()
|
|
772
|
+
|
|
773
|
+
if (bitmap == null) {
|
|
774
|
+
UiThreadUtil.runOnUiThread { promise.reject(Exception("Failed to extract frame")) }
|
|
775
|
+
return@Thread
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (maxWidth > 0 || maxHeight > 0) {
|
|
779
|
+
val origW = bitmap.width
|
|
780
|
+
val origH = bitmap.height
|
|
781
|
+
var targetW = if (maxWidth > 0) maxWidth else origW
|
|
782
|
+
var targetH = if (maxHeight > 0) maxHeight else origH
|
|
783
|
+
|
|
784
|
+
val ratioW = targetW.toFloat() / origW
|
|
785
|
+
val ratioH = targetH.toFloat() / origH
|
|
786
|
+
val ratio = min(min(ratioW, ratioH), 1f)
|
|
787
|
+
|
|
788
|
+
targetW = (origW * ratio).toInt()
|
|
789
|
+
targetH = (origH * ratio).toInt()
|
|
790
|
+
|
|
791
|
+
if (targetW != origW || targetH != origH) {
|
|
792
|
+
bitmap = Bitmap.createScaledBitmap(bitmap, targetW, targetH, true)
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
val ext = if (format == "png") "png" else "jpg"
|
|
797
|
+
val timestamp = System.currentTimeMillis() / 1000
|
|
798
|
+
val file = File(reactApplicationContext.cacheDir, "${VideoTrimmerUtil.FILE_PREFIX}_frame_${timestamp}.$ext")
|
|
799
|
+
|
|
800
|
+
val outputStream = FileOutputStream(file)
|
|
801
|
+
if (format == "png") {
|
|
802
|
+
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
|
|
803
|
+
} else {
|
|
804
|
+
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
|
|
805
|
+
}
|
|
806
|
+
outputStream.close()
|
|
807
|
+
|
|
808
|
+
val result = Arguments.createMap()
|
|
809
|
+
result.putString("outputPath", file.absolutePath)
|
|
810
|
+
UiThreadUtil.runOnUiThread { promise.resolve(result) }
|
|
811
|
+
} catch (e: Exception) {
|
|
812
|
+
UiThreadUtil.runOnUiThread { promise.reject(Exception("Frame extraction failed: ${e.message}")) }
|
|
813
|
+
}
|
|
814
|
+
}.start()
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Strips the video track via FFmpeg -vn. Default output is m4a (AAC) because the
|
|
818
|
+
// default FFmpegKit builds do not include libmp3lame for mp3 encoding.
|
|
819
|
+
fun extractAudio(url: String, options: ReadableMap?, promise: Promise) {
|
|
820
|
+
val outputExt = options?.getString("outputExt") ?: "m4a"
|
|
821
|
+
val outputFile = StorageUtil.getCacheOutputPath(reactApplicationContext, outputExt)
|
|
822
|
+
|
|
823
|
+
val cmds = arrayOf("-i", url, "-vn", "-y", outputFile)
|
|
824
|
+
Log.d(TAG, "extractAudio command: ${cmds.joinToString(" ")}")
|
|
825
|
+
|
|
826
|
+
FFmpegKit.executeWithArgumentsAsync(cmds, { session ->
|
|
827
|
+
val returnCode = session.returnCode
|
|
828
|
+
UiThreadUtil.runOnUiThread {
|
|
829
|
+
if (ReturnCode.isSuccess(returnCode)) {
|
|
830
|
+
val retriever = MediaMetadataRetriever()
|
|
831
|
+
var duration = 0.0
|
|
832
|
+
try {
|
|
833
|
+
retriever.setDataSource(outputFile)
|
|
834
|
+
duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toDoubleOrNull() ?: 0.0
|
|
835
|
+
retriever.release()
|
|
836
|
+
} catch (_: Exception) {
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
val result = Arguments.createMap()
|
|
840
|
+
result.putString("outputPath", outputFile)
|
|
841
|
+
result.putDouble("duration", duration)
|
|
842
|
+
promise.resolve(result)
|
|
843
|
+
} else {
|
|
844
|
+
val logs = session.allLogsAsString ?: ""
|
|
845
|
+
promise.reject(Exception("Extract audio failed: rc $returnCode\n$logs"))
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}, null, null)
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Re-encodes video with h264_mediacodec (hardware) at the requested quality/bitrate.
|
|
852
|
+
// Uses preset bitrate tiers for quality levels since Android's MediaCodec does not
|
|
853
|
+
// support CRF-style quality control like VideoToolbox's -global_quality on iOS.
|
|
854
|
+
fun compress(url: String, options: ReadableMap?, promise: Promise) {
|
|
855
|
+
val quality = options?.getString("quality") ?: "medium"
|
|
856
|
+
val bitrate = options?.getDouble("bitrate") ?: -1.0
|
|
857
|
+
val width = options?.getInt("width") ?: -1
|
|
858
|
+
val height = options?.getInt("height") ?: -1
|
|
859
|
+
val frameRate = options?.getDouble("frameRate") ?: -1.0
|
|
860
|
+
val outputExt = options?.getString("outputExt") ?: "mp4"
|
|
861
|
+
val removeAudio = options?.hasKey("removeAudio") == true && options.getBoolean("removeAudio")
|
|
862
|
+
|
|
863
|
+
val outputFile = StorageUtil.getCacheOutputPath(reactApplicationContext, outputExt)
|
|
864
|
+
|
|
865
|
+
val cmds = mutableListOf("-i", url)
|
|
866
|
+
val videoFilters = mutableListOf<String>()
|
|
867
|
+
|
|
868
|
+
if (width > 0 && height > 0) {
|
|
869
|
+
videoFilters.add("scale=$width:$height")
|
|
870
|
+
} else if (width > 0) {
|
|
871
|
+
videoFilters.add("scale=$width:-2")
|
|
872
|
+
} else if (height > 0) {
|
|
873
|
+
videoFilters.add("scale=-2:$height")
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (videoFilters.isNotEmpty()) {
|
|
877
|
+
cmds.addAll(listOf("-vf", videoFilters.joinToString(",")))
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
cmds.addAll(listOf("-c:v", "h264_mediacodec"))
|
|
881
|
+
|
|
882
|
+
if (bitrate > 0) {
|
|
883
|
+
cmds.addAll(listOf("-b:v", "${bitrate.toLong()}"))
|
|
884
|
+
} else {
|
|
885
|
+
val bv = when (quality) {
|
|
886
|
+
"low" -> "500K"
|
|
887
|
+
"high" -> "5M"
|
|
888
|
+
else -> "2M"
|
|
889
|
+
}
|
|
890
|
+
cmds.addAll(listOf("-b:v", bv))
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (frameRate > 0) {
|
|
894
|
+
cmds.addAll(listOf("-r", "$frameRate"))
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (removeAudio) {
|
|
898
|
+
cmds.add("-an")
|
|
899
|
+
} else {
|
|
900
|
+
cmds.addAll(listOf("-c:a", "aac"))
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
cmds.addAll(listOf("-y", outputFile))
|
|
904
|
+
Log.d(TAG, "compress command: ${cmds.joinToString(" ")}")
|
|
905
|
+
|
|
906
|
+
FFmpegKit.executeWithArgumentsAsync(cmds.toTypedArray(), { session ->
|
|
907
|
+
val returnCode = session.returnCode
|
|
908
|
+
UiThreadUtil.runOnUiThread {
|
|
909
|
+
if (ReturnCode.isSuccess(returnCode)) {
|
|
910
|
+
val result = Arguments.createMap()
|
|
911
|
+
result.putString("outputPath", outputFile)
|
|
912
|
+
promise.resolve(result)
|
|
913
|
+
} else {
|
|
914
|
+
val logs = session.allLogsAsString ?: ""
|
|
915
|
+
promise.reject(Exception("Compression failed: rc $returnCode\n$logs"))
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}, null, null)
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// Two-pass GIF conversion: pass 1 generates an optimal color palette (palettegen),
|
|
922
|
+
// pass 2 encodes the GIF using that palette (paletteuse) for better color accuracy.
|
|
923
|
+
fun toGif(url: String, options: ReadableMap?, promise: Promise) {
|
|
924
|
+
val startTime = options?.getDouble("startTime") ?: 0.0
|
|
925
|
+
val endTime = options?.getDouble("endTime") ?: -1.0
|
|
926
|
+
val fps = options?.getInt("fps") ?: 10
|
|
927
|
+
val width = options?.getInt("width") ?: -1
|
|
928
|
+
|
|
929
|
+
val timestamp = System.currentTimeMillis() / 1000
|
|
930
|
+
val paletteFile = File(reactApplicationContext.cacheDir, "${VideoTrimmerUtil.FILE_PREFIX}_palette_${timestamp}.png")
|
|
931
|
+
val outputFile = File(reactApplicationContext.cacheDir, "${VideoTrimmerUtil.FILE_PREFIX}_gif_${timestamp}.gif")
|
|
932
|
+
|
|
933
|
+
val scaleExpr = if (width > 0) "$width:-1" else "-1:-1"
|
|
934
|
+
val filterBase = "fps=$fps,scale=$scaleExpr:flags=lanczos"
|
|
935
|
+
|
|
936
|
+
val timeArgs = mutableListOf<String>()
|
|
937
|
+
if (startTime > 0) {
|
|
938
|
+
timeArgs.addAll(listOf("-ss", "${startTime}ms"))
|
|
939
|
+
}
|
|
940
|
+
if (endTime > 0) {
|
|
941
|
+
timeArgs.addAll(listOf("-to", "${endTime}ms"))
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
val pass1 = (timeArgs + listOf("-i", url, "-vf", "$filterBase,palettegen", "-y", paletteFile.absolutePath)).toTypedArray()
|
|
945
|
+
Log.d(TAG, "toGif pass1 command: ${pass1.joinToString(" ")}")
|
|
946
|
+
|
|
947
|
+
FFmpegKit.executeWithArgumentsAsync(pass1, { session ->
|
|
948
|
+
if (!ReturnCode.isSuccess(session.returnCode)) {
|
|
949
|
+
paletteFile.delete()
|
|
950
|
+
val logs = session.allLogsAsString ?: ""
|
|
951
|
+
UiThreadUtil.runOnUiThread { promise.reject(Exception("GIF palette generation failed\n$logs")) }
|
|
952
|
+
return@executeWithArgumentsAsync
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
val pass2 = (
|
|
956
|
+
timeArgs + listOf(
|
|
957
|
+
"-i", url, "-i", paletteFile.absolutePath, "-lavfi",
|
|
958
|
+
"$filterBase [x]; [x][1:v] paletteuse",
|
|
959
|
+
"-y",
|
|
960
|
+
outputFile.absolutePath
|
|
961
|
+
)
|
|
962
|
+
).toTypedArray()
|
|
963
|
+
Log.d(TAG, "toGif pass2 command: ${pass2.joinToString(" ")}")
|
|
964
|
+
|
|
965
|
+
FFmpegKit.executeWithArgumentsAsync(pass2, { session2 ->
|
|
966
|
+
paletteFile.delete()
|
|
967
|
+
UiThreadUtil.runOnUiThread {
|
|
968
|
+
if (ReturnCode.isSuccess(session2.returnCode)) {
|
|
969
|
+
val result = Arguments.createMap()
|
|
970
|
+
result.putString("outputPath", outputFile.absolutePath)
|
|
971
|
+
promise.resolve(result)
|
|
972
|
+
} else {
|
|
973
|
+
val logs = session2.allLogsAsString ?: ""
|
|
974
|
+
promise.reject(Exception("GIF creation failed\n$logs"))
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}, null, null)
|
|
978
|
+
}, null, null)
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Concatenates multiple local video files using FFmpeg's concat *filter* (not demuxer).
|
|
982
|
+
// Each input is normalized to the first clip's resolution via scale+pad+setsar+format
|
|
983
|
+
// before entering the concat, so clips with different dimensions, pixel formats, or SARs
|
|
984
|
+
// merge correctly (mismatched inputs get letterboxed/pillarboxed with black bars).
|
|
985
|
+
//
|
|
986
|
+
// Bitrate: probes all input videos via MediaMetadataRetriever and uses the highest
|
|
987
|
+
// detected bitrate as the output target so quality matches the best source clip.
|
|
988
|
+
// Falls back to 10 Mbps if no bitrate can be read.
|
|
989
|
+
//
|
|
990
|
+
// Limitation: only supports local file paths. Remote URLs are not supported because the
|
|
991
|
+
// default FFmpegKit build does not include OpenSSL (--disable-openssl).
|
|
992
|
+
fun merge(urls: ReadableArray, options: ReadableMap?, promise: Promise) {
|
|
993
|
+
val outputExt = options?.getString("outputExt") ?: "mp4"
|
|
994
|
+
val outputFile = StorageUtil.getCacheOutputPath(reactApplicationContext, outputExt)
|
|
995
|
+
|
|
996
|
+
val n = urls.size()
|
|
997
|
+
if (n == 0) {
|
|
998
|
+
promise.reject(Exception("No input URLs"))
|
|
999
|
+
return
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
val cmds = mutableListOf<String>()
|
|
1003
|
+
var maxBitrate = 0L
|
|
1004
|
+
for (i in 0 until n) {
|
|
1005
|
+
val urlStr = urls.getString(i) ?: continue
|
|
1006
|
+
cmds.addAll(listOf("-i", urlStr))
|
|
1007
|
+
try {
|
|
1008
|
+
val retriever = MediaMetadataRetriever()
|
|
1009
|
+
retriever.setDataSource(reactApplicationContext, Uri.parse(urlStr))
|
|
1010
|
+
val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)?.toLongOrNull() ?: 0L
|
|
1011
|
+
if (bitrate > maxBitrate) maxBitrate = bitrate
|
|
1012
|
+
retriever.release()
|
|
1013
|
+
} catch (_: Exception) {}
|
|
1014
|
+
}
|
|
1015
|
+
val bitrateStr = if (maxBitrate > 0) "$maxBitrate" else "10M"
|
|
1016
|
+
|
|
1017
|
+
// Use the first clip's dimensions and frame rate as the target for all inputs.
|
|
1018
|
+
var targetW = 1280; var targetH = 720
|
|
1019
|
+
var targetFps = 30
|
|
1020
|
+
try {
|
|
1021
|
+
val retriever = MediaMetadataRetriever()
|
|
1022
|
+
retriever.setDataSource(reactApplicationContext, Uri.parse(urls.getString(0)))
|
|
1023
|
+
targetW = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 1280
|
|
1024
|
+
targetH = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 720
|
|
1025
|
+
val fpsStr = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)
|
|
1026
|
+
val fps = fpsStr?.toDoubleOrNull()?.let { kotlin.math.ceil(it).toInt() } ?: 30
|
|
1027
|
+
targetFps = fps.coerceIn(1, 30)
|
|
1028
|
+
retriever.release()
|
|
1029
|
+
} catch (_: Exception) {}
|
|
1030
|
+
|
|
1031
|
+
// Normalize each input to the same resolution, pixel format, SAR, and frame rate
|
|
1032
|
+
// before concat. The fps filter prevents massive frame duplication when inputs have
|
|
1033
|
+
// very different frame rates (e.g. 24fps + 60fps would cause thousands of dupes).
|
|
1034
|
+
val scaleFilter = "scale=$targetW:$targetH:force_original_aspect_ratio=decrease,pad=$targetW:$targetH:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p,fps=$targetFps"
|
|
1035
|
+
val scaleParts = (0 until n).joinToString(";") { "[$it:v:0]${scaleFilter}[v$it]" }
|
|
1036
|
+
val concatInputs = (0 until n).joinToString("") { "[v$it][$it:a:0]" }
|
|
1037
|
+
val filterComplex = "$scaleParts;${concatInputs}concat=n=$n:v=1:a=1[outv][outa]"
|
|
1038
|
+
|
|
1039
|
+
cmds.addAll(listOf(
|
|
1040
|
+
"-filter_complex", filterComplex,
|
|
1041
|
+
"-map", "[outv]", "-map", "[outa]",
|
|
1042
|
+
"-c:v", "h264_mediacodec", "-b:v", bitrateStr,
|
|
1043
|
+
"-c:a", "aac",
|
|
1044
|
+
"-y", outputFile
|
|
1045
|
+
))
|
|
1046
|
+
Log.d(TAG, "merge command: ${cmds.joinToString(" ")}")
|
|
1047
|
+
|
|
1048
|
+
FFmpegKit.executeWithArgumentsAsync(cmds.toTypedArray(), { session ->
|
|
1049
|
+
val returnCode = session.returnCode
|
|
1050
|
+
UiThreadUtil.runOnUiThread {
|
|
1051
|
+
if (ReturnCode.isSuccess(returnCode)) {
|
|
1052
|
+
var duration = 0.0
|
|
1053
|
+
try {
|
|
1054
|
+
val retriever = MediaMetadataRetriever()
|
|
1055
|
+
retriever.setDataSource(outputFile)
|
|
1056
|
+
duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toDoubleOrNull() ?: 0.0
|
|
1057
|
+
retriever.release()
|
|
1058
|
+
} catch (_: Exception) {
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
val result = Arguments.createMap()
|
|
1062
|
+
result.putString("outputPath", outputFile)
|
|
1063
|
+
result.putDouble("duration", duration)
|
|
1064
|
+
promise.resolve(result)
|
|
1065
|
+
} else {
|
|
1066
|
+
val logs = session.allLogsAsString ?: ""
|
|
1067
|
+
promise.reject(Exception("Merge failed: rc $returnCode\n$logs"))
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}, null, null)
|
|
1071
|
+
}
|
|
1072
|
+
|
|
698
1073
|
private fun saveFileToExternalStorage(file: File) {
|
|
699
1074
|
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
|
|
700
1075
|
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
|
@@ -724,6 +1099,84 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
724
1099
|
reactApplicationContext.currentActivity?.startActivity(Intent.createChooser(shareIntent, "Share file"))
|
|
725
1100
|
}
|
|
726
1101
|
|
|
1102
|
+
// Saves a file to the device gallery. Delegates to StorageUtil.saveToGallery which
|
|
1103
|
+
// detects image vs video by extension and uses the appropriate MediaStore collection.
|
|
1104
|
+
fun saveToPhoto(filePath: String, promise: Promise) {
|
|
1105
|
+
try {
|
|
1106
|
+
val file = File(filePath)
|
|
1107
|
+
if (!file.exists()) {
|
|
1108
|
+
promise.reject(Exception("File does not exist at path: $filePath"))
|
|
1109
|
+
return
|
|
1110
|
+
}
|
|
1111
|
+
StorageUtil.saveToGallery(reactApplicationContext, filePath)
|
|
1112
|
+
val result = Arguments.createMap()
|
|
1113
|
+
result.putBoolean("success", true)
|
|
1114
|
+
promise.resolve(result)
|
|
1115
|
+
} catch (e: Exception) {
|
|
1116
|
+
promise.reject(Exception("Failed to save to photo library: ${e.message}"))
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// Opens Android's SAF (Storage Access Framework) document picker via ACTION_CREATE_DOCUMENT
|
|
1121
|
+
// so the user can choose where to save. The promise is stored and resolved in onActivityResult.
|
|
1122
|
+
fun saveToDocuments(filePath: String, promise: Promise) {
|
|
1123
|
+
val file = File(filePath)
|
|
1124
|
+
if (!file.exists()) {
|
|
1125
|
+
promise.reject(Exception("File does not exist at path: $filePath"))
|
|
1126
|
+
return
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
val activity = reactApplicationContext.currentActivity
|
|
1130
|
+
if (activity == null) {
|
|
1131
|
+
promise.reject(Exception("No activity available"))
|
|
1132
|
+
return
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
pendingSaveToDocumentsPromise = promise
|
|
1136
|
+
pendingSaveToDocumentsFile = file
|
|
1137
|
+
|
|
1138
|
+
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
|
|
1139
|
+
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
|
1140
|
+
intent.type = "*/*"
|
|
1141
|
+
intent.putExtra(Intent.EXTRA_TITLE, file.name)
|
|
1142
|
+
activity.startActivityForResult(intent, REQUEST_CODE_SAVE_TO_DOCUMENTS)
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// Opens the system share sheet via ACTION_SEND. Uses FileProvider to generate a
|
|
1146
|
+
// content:// URI and grants read permission to all potential share targets.
|
|
1147
|
+
fun share(filePath: String, promise: Promise) {
|
|
1148
|
+
val file = File(filePath)
|
|
1149
|
+
if (!file.exists()) {
|
|
1150
|
+
promise.reject(Exception("File does not exist at path: $filePath"))
|
|
1151
|
+
return
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
val activity = reactApplicationContext.currentActivity
|
|
1155
|
+
if (activity == null) {
|
|
1156
|
+
promise.reject(Exception("No activity available"))
|
|
1157
|
+
return
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
val context: Context = reactApplicationContext
|
|
1161
|
+
val fileUri = FileProvider.getUriForFile(context, context.packageName + ".provider", file)
|
|
1162
|
+
|
|
1163
|
+
val shareIntent = Intent(Intent.ACTION_SEND)
|
|
1164
|
+
shareIntent.type = "*/*"
|
|
1165
|
+
shareIntent.putExtra(Intent.EXTRA_STREAM, fileUri)
|
|
1166
|
+
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
1167
|
+
|
|
1168
|
+
for (resolveInfo in context.packageManager.queryIntentActivities(shareIntent, PackageManager.MATCH_DEFAULT_ONLY)) {
|
|
1169
|
+
val packageName = resolveInfo.activityInfo.packageName
|
|
1170
|
+
context.grantUriPermission(packageName, fileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
activity.startActivity(Intent.createChooser(shareIntent, "Share file"))
|
|
1174
|
+
|
|
1175
|
+
val result = Arguments.createMap()
|
|
1176
|
+
result.putBoolean("success", true)
|
|
1177
|
+
promise.resolve(result)
|
|
1178
|
+
}
|
|
1179
|
+
|
|
727
1180
|
fun cleanup() {
|
|
728
1181
|
reactApplicationContext.removeLifecycleEventListener(this)
|
|
729
1182
|
}
|
|
@@ -732,5 +1185,6 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
732
1185
|
const val NAME = "VideoTrim"
|
|
733
1186
|
const val TAG = "VideoTrimModule"
|
|
734
1187
|
const val REQUEST_CODE_SAVE_FILE = 1
|
|
1188
|
+
const val REQUEST_CODE_SAVE_TO_DOCUMENTS = 2
|
|
735
1189
|
}
|
|
736
1190
|
}
|