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.
- package/README.md +287 -6
- 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 +39 -17
- package/android/src/main/java/com/videotrim/widgets/AudioWaveformView.kt +92 -0
- package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.kt +579 -8
- 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 +22 -0
- 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/AudioWaveformView.swift +75 -0
- package/ios/VideoTrim.mm +180 -1
- package/ios/VideoTrim.swift +632 -39
- package/ios/VideoTrimmer.swift +300 -0
- package/ios/VideoTrimmerViewController.swift +129 -4
- package/lib/module/NativeVideoTrim.js +52 -0
- package/lib/module/NativeVideoTrim.js.map +1 -1
- package/lib/module/index.js +155 -2
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/NativeVideoTrim.d.ts +158 -0
- package/lib/typescript/src/NativeVideoTrim.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +65 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/NativeVideoTrim.ts +171 -0
- package/src/index.tsx +211 -2
|
@@ -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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
saveVideoUsingTraditionalStorage(context, videoFile)
|
|
74
|
-
} catch (e: IOException) {
|
|
75
|
-
throw RuntimeException(e)
|
|
76
|
-
}
|
|
96
|
+
saveVideoToGallery(context, file)
|
|
77
97
|
}
|
|
78
98
|
}
|
|
79
99
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
101
|
-
val
|
|
102
|
-
val
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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
|
-
|
|
123
|
-
"-
|
|
124
|
-
"-
|
|
125
|
-
"-
|
|
126
|
-
|
|
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
|
-
|
|
132
|
-
"-c", "copy",
|
|
133
|
-
|
|
134
|
-
|
|
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
|
+
}
|