react-native-video-trim 7.1.0 → 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.
@@ -25,23 +25,39 @@ object StorageUtil {
25
25
  return file.absolutePath
26
26
  }
27
27
 
28
+ // Headless API outputs (compress, toGif, merge, extractAudio, getFrameAt) go to the
29
+ // cache directory. The OS may purge these files under storage pressure when the app is
30
+ // not running, which avoids unbounded storage growth from repeated headless operations.
31
+ fun getCacheOutputPath(context: Context, outputExt: String): String {
32
+ val timestamp = System.currentTimeMillis() / 1000
33
+ val file = File(context.cacheDir, "${VideoTrimmerUtil.FILE_PREFIX}_${timestamp}.$outputExt")
34
+ return file.absolutePath
35
+ }
36
+
28
37
  fun isFileExists(filePath: String?): Boolean {
29
38
  if (TextUtils.isEmpty(filePath)) return false
30
39
  return File(filePath!!).exists()
31
40
  }
32
41
 
42
+ // Scans both the persistent directory (showEditor/trim outputs) and the cache
43
+ // directory (headless API outputs) for files matching our prefix.
33
44
  fun listFiles(context: Context): Array<String> {
34
- val filesDir = context.filesDir
35
- val files = filesDir.listFiles { _, name -> name.startsWith(VideoTrimmerUtil.FILE_PREFIX) }
36
-
37
- return files?.map { it.absolutePath }?.toTypedArray() ?: emptyArray()
45
+ val dirs = listOf(context.filesDir, context.cacheDir)
46
+ return dirs.flatMap { dir ->
47
+ dir.listFiles { _, name -> name.startsWith(VideoTrimmerUtil.FILE_PREFIX) }?.toList() ?: emptyList()
48
+ }.map { it.absolutePath }.toTypedArray()
38
49
  }
39
50
 
51
+ // Path-restricted delete: only allows deletion of files inside our filesDir or cacheDir
52
+ // to prevent accidental deletion of arbitrary files on the device.
40
53
  fun deleteFile(path: String?): Boolean {
41
54
  if (TextUtils.isEmpty(path)) return true
42
55
  val file = File(path!!).canonicalFile
43
- val allowedDir = BaseUtils.getContext().filesDir.canonicalFile
44
- if (!file.path.startsWith(allowedDir.path)) return false
56
+ val allowedDirs = listOf(
57
+ BaseUtils.getContext().filesDir.canonicalFile,
58
+ BaseUtils.getContext().cacheDir.canonicalFile,
59
+ )
60
+ if (allowedDirs.none { file.path.startsWith(it.path) }) return false
45
61
  return deleteFile(file)
46
62
  }
47
63
 
@@ -62,46 +78,89 @@ object StorageUtil {
62
78
  return file.delete()
63
79
  }
64
80
 
65
- @Throws(IOException::class)
66
- fun saveVideoToGallery(context: ReactApplicationContext, videoFilePath: String?) {
67
- val videoFile = File(videoFilePath!!)
81
+ private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "heic", "heif")
68
82
 
69
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
70
- saveVideoUsingMediaStore(context, videoFile)
83
+ fun isImageFile(file: File): Boolean {
84
+ val ext = file.extension.lowercase()
85
+ return ext in IMAGE_EXTENSIONS
86
+ }
87
+
88
+ // Dispatches to the correct MediaStore collection (Images vs Video) based on file
89
+ // extension. Using the wrong collection causes the Photos app to misidentify the file.
90
+ @Throws(IOException::class)
91
+ fun saveToGallery(context: ReactApplicationContext, filePath: String?) {
92
+ val file = File(filePath!!)
93
+ if (isImageFile(file)) {
94
+ saveImageToGallery(context, file)
71
95
  } else {
72
- try {
73
- saveVideoUsingTraditionalStorage(context, videoFile)
74
- } catch (e: IOException) {
75
- throw RuntimeException(e)
76
- }
96
+ saveVideoToGallery(context, file)
77
97
  }
78
98
  }
79
99
 
80
- private fun saveVideoUsingMediaStore(context: Context, videoFile: File) {
81
- val values = ContentValues().apply {
82
- put(MediaStore.Video.Media.TITLE, "My Video Title")
83
- put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
84
- put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
85
- }
86
- val uri: Uri? = context.contentResolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)
87
-
88
- if (uri != null) {
89
- try {
90
- val outputStream = context.contentResolver.openOutputStream(uri)
91
- copyFile(videoFile, outputStream!!)
92
- MediaScannerConnection.scanFile(context, arrayOf(uri.toString()), arrayOf("video/*"), null)
93
- } catch (e: IOException) {
94
- e.printStackTrace()
100
+ // On Android Q+ (API 29+), uses the IS_PENDING pattern: the MediaStore entry is
101
+ // created as pending (invisible to other apps), the file is copied, then IS_PENDING
102
+ // is cleared to make it visible. This prevents partial files from appearing in the
103
+ // gallery during the write.
104
+ @Throws(IOException::class)
105
+ private fun saveVideoToGallery(context: ReactApplicationContext, videoFile: File) {
106
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
107
+ val values = ContentValues().apply {
108
+ put(MediaStore.Video.Media.DISPLAY_NAME, videoFile.name)
109
+ put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
110
+ put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
111
+ put(MediaStore.Video.Media.IS_PENDING, 1)
112
+ }
113
+ val resolver = context.contentResolver
114
+ val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)
115
+ if (uri != null) {
116
+ resolver.openOutputStream(uri)?.use { os -> copyFile(videoFile, os) }
117
+ values.clear()
118
+ values.put(MediaStore.Video.Media.IS_PENDING, 0)
119
+ resolver.update(uri, values, null, null)
95
120
  }
121
+ } else {
122
+ val galleryDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)
123
+ val dest = File(galleryDir, videoFile.name)
124
+ copyFile(videoFile, dest)
125
+ MediaScannerConnection.scanFile(context, arrayOf(dest.absolutePath), arrayOf("video/*"), null)
96
126
  }
97
127
  }
98
128
 
129
+ // Same IS_PENDING pattern as saveVideoToGallery but targets MediaStore.Images.Media
130
+ // and Environment.DIRECTORY_PICTURES. MIME type is inferred from the file extension.
99
131
  @Throws(IOException::class)
100
- private fun saveVideoUsingTraditionalStorage(context: Context, videoFile: File) {
101
- val galleryDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)
102
- val destinationFile = File(galleryDirectory, videoFile.name)
103
- copyFile(videoFile, destinationFile)
104
- MediaScannerConnection.scanFile(context, arrayOf(destinationFile.absolutePath), arrayOf("video/*"), null)
132
+ private fun saveImageToGallery(context: ReactApplicationContext, imageFile: File) {
133
+ val ext = imageFile.extension.lowercase()
134
+ val mimeType = when (ext) {
135
+ "png" -> "image/png"
136
+ "gif" -> "image/gif"
137
+ "webp" -> "image/webp"
138
+ "bmp" -> "image/bmp"
139
+ "heic", "heif" -> "image/heic"
140
+ else -> "image/jpeg"
141
+ }
142
+
143
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
144
+ val values = ContentValues().apply {
145
+ put(MediaStore.Images.Media.DISPLAY_NAME, imageFile.name)
146
+ put(MediaStore.Images.Media.MIME_TYPE, mimeType)
147
+ put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
148
+ put(MediaStore.Images.Media.IS_PENDING, 1)
149
+ }
150
+ val resolver = context.contentResolver
151
+ val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
152
+ if (uri != null) {
153
+ resolver.openOutputStream(uri)?.use { os -> copyFile(imageFile, os) }
154
+ values.clear()
155
+ values.put(MediaStore.Images.Media.IS_PENDING, 0)
156
+ resolver.update(uri, values, null, null)
157
+ }
158
+ } else {
159
+ val picturesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
160
+ val dest = File(picturesDir, imageFile.name)
161
+ copyFile(imageFile, dest)
162
+ MediaScannerConnection.scanFile(context, arrayOf(dest.absolutePath), arrayOf(mimeType), null)
163
+ }
105
164
  }
106
165
 
107
166
  @Throws(IOException::class)
@@ -38,6 +38,21 @@ object VideoTrimmerUtil {
38
38
  @JvmField val THUMB_WIDTH = UnitConverter.dpToPx(25)
39
39
  private const val THUMB_RESOLUTION_RES = 2
40
40
 
41
+ internal fun buildAtempoChain(speed: Double): String {
42
+ var remaining = speed
43
+ val filters = mutableListOf<String>()
44
+ while (remaining < 0.5) {
45
+ filters.add("atempo=0.5")
46
+ remaining /= 0.5
47
+ }
48
+ while (remaining > 2.0) {
49
+ filters.add("atempo=2.0")
50
+ remaining /= 2.0
51
+ }
52
+ filters.add("atempo=$remaining")
53
+ return filters.joinToString(",")
54
+ }
55
+
41
56
  fun trim(
42
57
  inputFile: String,
43
58
  outputFile: String,
@@ -51,6 +66,8 @@ object VideoTrimmerUtil {
51
66
  videoHeight: Int,
52
67
  videoBitrate: Long,
53
68
  enablePreciseTrimming: Boolean,
69
+ removeAudio: Boolean,
70
+ speed: Double,
54
71
  callback: VideoTrimListener
55
72
  ): FFmpegSession {
56
73
  val currentDate = Date()
@@ -67,9 +84,9 @@ object VideoTrimmerUtil {
67
84
 
68
85
  val hasUserTransform = userRotationCount != 0 || userIsFlipped
69
86
  // Re-encode is required when: (1) user applied flip/rotate, (2) user cropped, or
70
- // (3) enablePreciseTrimming is on. In all three cases, -c copy won't work because
71
- // either we need video filters or we need frame-accurate cut points.
72
- val needsReEncode = hasUserTransform || cropNormalized != null || enablePreciseTrimming
87
+ // (3) enablePreciseTrimming is on, or (4) speed != 1.0. In those cases, -c copy
88
+ // won't work because either we need video filters or we need frame-accurate cut points.
89
+ val needsReEncode = hasUserTransform || cropNormalized != null || enablePreciseTrimming || speed != 1.0
73
90
 
74
91
  if (needsReEncode) {
75
92
  val videoFilters = mutableListOf<String>()
@@ -105,6 +122,10 @@ object VideoTrimmerUtil {
105
122
  }
106
123
  }
107
124
 
125
+ if (speed != 1.0) {
126
+ videoFilters.add("setpts=${1.0 / speed}*PTS")
127
+ }
128
+
108
129
  val filterString = videoFilters.joinToString(",")
109
130
  // Preserve source quality by matching the original bitrate. Falls back to 10 Mbps.
110
131
  val bitrateStr = if (videoBitrate > 0) "$videoBitrate" else "10M"
@@ -118,21 +139,22 @@ object VideoTrimmerUtil {
118
139
  // h264_mediacodec: Android's hardware H.264 encoder — fast and energy-efficient.
119
140
  // Note: Android FFmpegKit auto-rotates by default, so no -noautorotate is needed.
120
141
  // The transpose filters above only handle user-initiated rotation, not source metadata.
121
- cmds.addAll(listOf(
122
- "-c:v", "h264_mediacodec",
123
- "-b:v", bitrateStr,
124
- "-c:a", "copy",
125
- "-metadata", "creation_time=$formattedDateTime",
126
- outputFile
127
- ))
142
+ cmds.addAll(listOf("-c:v", "h264_mediacodec", "-b:v", bitrateStr))
143
+ when {
144
+ removeAudio -> cmds.add("-an")
145
+ speed != 1.0 -> cmds.addAll(listOf("-af", buildAtempoChain(speed)))
146
+ else -> cmds.addAll(listOf("-c:a", "copy"))
147
+ }
148
+ cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", outputFile))
128
149
  } else {
129
150
  // Stream copy: no re-encoding, extremely fast but only cuts at keyframes.
130
- cmds.addAll(listOf(
131
- "-i", inputFile,
132
- "-c", "copy",
133
- "-metadata", "creation_time=$formattedDateTime",
134
- outputFile
135
- ))
151
+ cmds.addAll(listOf("-i", inputFile))
152
+ if (removeAudio) {
153
+ cmds.addAll(listOf("-c:v", "copy", "-an"))
154
+ } else {
155
+ cmds.addAll(listOf("-c", "copy"))
156
+ }
157
+ cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", outputFile))
136
158
  }
137
159
 
138
160
  val command = cmds.toTypedArray()
@@ -204,7 +226,7 @@ object VideoTrimmerUtil {
204
226
  endPosition: Long,
205
227
  callback: SingleCallback<Bitmap, Int>
206
228
  ) {
207
- BackgroundExecutor.execute(object : BackgroundExecutor.Task("", 0L, "") {
229
+ BackgroundExecutor.execute(object : BackgroundExecutor.Task("initial_thumbs", 0L, "") {
208
230
  override fun execute() {
209
231
  try {
210
232
  val interval = (endPosition - startPosition) / (totalThumbsCount - 1)
@@ -0,0 +1,92 @@
1
+ package com.videotrim.widgets
2
+
3
+ import android.content.Context
4
+ import android.graphics.Canvas
5
+ import android.graphics.Color
6
+ import android.graphics.Paint
7
+ import android.graphics.RectF
8
+ import android.util.AttributeSet
9
+ import android.view.View
10
+
11
+ /**
12
+ * Custom View that draws an audio waveform as a row of vertical rounded-rect bars.
13
+ *
14
+ * Each bar's height is driven by a normalized amplitude value in [0, 1].
15
+ * The view recalculates bar count from its own width and maps the amplitudes
16
+ * array proportionally, so it works correctly regardless of whether the
17
+ * amplitudes array has more or fewer entries than the visible bar count.
18
+ *
19
+ * The background color (set via [View.setBackgroundColor]) provides the
20
+ * waveform track color; the bars are drawn on top with [barColor].
21
+ */
22
+ class AudioWaveformView @JvmOverloads constructor(
23
+ context: Context,
24
+ attrs: AttributeSet? = null,
25
+ defStyleAttr: Int = 0
26
+ ) : View(context, attrs, defStyleAttr) {
27
+
28
+ var amplitudes: FloatArray = FloatArray(0)
29
+ private set
30
+ private val barPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
31
+ color = Color.WHITE
32
+ style = Paint.Style.FILL
33
+ }
34
+ private val barRect = RectF()
35
+
36
+ var barColor: Int
37
+ get() = barPaint.color
38
+ set(value) {
39
+ barPaint.color = value
40
+ invalidate()
41
+ }
42
+
43
+ var barWidthPx: Float = 0f
44
+ set(value) {
45
+ field = value
46
+ invalidate()
47
+ }
48
+
49
+ var barGapPx: Float = 0f
50
+ set(value) {
51
+ field = value
52
+ invalidate()
53
+ }
54
+
55
+ var barCornerRadiusPx: Float = 0f
56
+ set(value) {
57
+ field = value
58
+ invalidate()
59
+ }
60
+
61
+ fun setAmplitudes(data: FloatArray) {
62
+ amplitudes = data
63
+ invalidate()
64
+ }
65
+
66
+ override fun onDraw(canvas: Canvas) {
67
+ super.onDraw(canvas)
68
+ if (amplitudes.isEmpty()) return
69
+
70
+ val totalHeight = height.toFloat()
71
+ val step = barWidthPx + barGapPx
72
+ if (step <= 0f) return
73
+ val barCount = (width.toFloat() / step).toInt()
74
+ if (barCount <= 0) return
75
+
76
+ // Keep bars from touching the container edges
77
+ val verticalPadding = barWidthPx * 1.5f
78
+ val drawableHeight = totalHeight - verticalPadding * 2f
79
+ if (drawableHeight <= 0f) return
80
+ val minBarHeight = barWidthPx
81
+
82
+ for (i in 0 until barCount) {
83
+ val ampIndex = (i * amplitudes.size / barCount).coerceIn(0, amplitudes.size - 1)
84
+ val amp = amplitudes[ampIndex]
85
+ val barHeight = (amp * drawableHeight).coerceAtLeast(minBarHeight)
86
+ val x = i * step
87
+ val y = verticalPadding + (drawableHeight - barHeight) / 2f
88
+ barRect.set(x, y, x + barWidthPx, y + barHeight)
89
+ canvas.drawRoundRect(barRect, barCornerRadiusPx, barCornerRadiusPx, barPaint)
90
+ }
91
+ }
92
+ }