react-native-video-trim 7.1.1 → 8.0.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.
@@ -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.saveVideoToGallery(reactApplicationContext, outputFile)
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
- var cmds = arrayOf(
580
- "-ss",
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 ?: run {
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
- // The only reason to re-encode here is enablePreciseTrimming for frame-accurate cuts.
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
- if (enablePrecise) {
599
- // Match source bitrate to preserve quality; fall back to 10 Mbps.
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
- // h264_mediacodec: Android's hardware H.264 encoder.
611
- // No -noautorotate needed — FFmpegKit on Android auto-rotates correctly.
612
- cmds += arrayOf(
613
- "-i", url,
614
- "-c:v", "h264_mediacodec",
615
- "-b:v", bitrateStr,
616
- "-c:a", "copy",
617
- "-metadata", "creation_time=$formattedDateTime",
618
- resolvedOutputFile
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
- // Stream copy: no re-encoding, extremely fast but only cuts at keyframes.
622
- cmds += arrayOf(
623
- "-i", url,
624
- "-c", "copy",
625
- "-metadata", "creation_time=$formattedDateTime",
626
- resolvedOutputFile
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.saveVideoToGallery(reactApplicationContext, resolvedOutputFile)
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
  }