react-native-video-trim 6.1.0 → 6.2.1
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/android/src/main/java/com/videotrim/enums/{ErrorCode.java → ErrorCode.kt} +2 -2
- package/android/src/main/java/com/videotrim/interfaces/IVideoTrimmerView.kt +5 -0
- package/android/src/main/java/com/videotrim/interfaces/VideoTrimListener.kt +16 -0
- package/android/src/main/java/com/videotrim/utils/MediaMetadataUtil.kt +84 -0
- package/android/src/main/java/com/videotrim/utils/StorageUtil.kt +129 -0
- package/android/src/main/java/com/videotrim/utils/VideoTrimmerUtil.kt +163 -0
- package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.kt +1170 -0
- package/ios/VideoTrimmer.swift +102 -3
- package/ios/VideoTrimmerViewController.swift +18 -0
- package/package.json +2 -2
- package/android/src/main/java/com/videotrim/interfaces/IVideoTrimmerView.java +0 -5
- package/android/src/main/java/com/videotrim/interfaces/VideoTrimListener.java +0 -19
- package/android/src/main/java/com/videotrim/utils/MediaMetadataUtil.java +0 -92
- package/android/src/main/java/com/videotrim/utils/StorageUtil.java +0 -147
- package/android/src/main/java/com/videotrim/utils/VideoTrimmerUtil.java +0 -171
- package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.java +0 -1248
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
package com.videotrim.interfaces
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.WritableMap
|
|
4
|
+
import com.videotrim.enums.ErrorCode
|
|
5
|
+
|
|
6
|
+
interface VideoTrimListener {
|
|
7
|
+
fun onLoad(duration: Int)
|
|
8
|
+
fun onTrimmingProgress(percentage: Int)
|
|
9
|
+
fun onFinishTrim(url: String, startMs: Long, endMs: Long, videoDuration: Int)
|
|
10
|
+
fun onCancelTrim()
|
|
11
|
+
fun onError(errorMessage: String?, errorCode: ErrorCode)
|
|
12
|
+
fun onCancel()
|
|
13
|
+
fun onSave()
|
|
14
|
+
fun onLog(log: WritableMap)
|
|
15
|
+
fun onStatistics(statistics: WritableMap)
|
|
16
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
package com.videotrim.utils
|
|
2
|
+
|
|
3
|
+
import android.media.MediaMetadataRetriever
|
|
4
|
+
import android.net.Uri
|
|
5
|
+
import android.util.Log
|
|
6
|
+
import java.io.IOException
|
|
7
|
+
|
|
8
|
+
object MediaMetadataUtil {
|
|
9
|
+
|
|
10
|
+
private const val TAG = "MediaMetadataUtil"
|
|
11
|
+
|
|
12
|
+
fun getMediaMetadataRetriever(source: String): MediaMetadataRetriever? {
|
|
13
|
+
val retriever = MediaMetadataRetriever()
|
|
14
|
+
return try {
|
|
15
|
+
if (source.startsWith("http://") || source.startsWith("https://")) {
|
|
16
|
+
retriever.setDataSource(source, HashMap())
|
|
17
|
+
} else {
|
|
18
|
+
var filePath = source
|
|
19
|
+
|
|
20
|
+
if (!StorageUtil.isFileExists(filePath)) {
|
|
21
|
+
Log.e(TAG, "File does not exist, trying to parse as URI: $source")
|
|
22
|
+
|
|
23
|
+
val uri = Uri.parse(source)
|
|
24
|
+
filePath = uri.path ?: ""
|
|
25
|
+
|
|
26
|
+
if (!StorageUtil.isFileExists(filePath)) {
|
|
27
|
+
Log.e(TAG, "File does not exist at path: $filePath")
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
retriever.setDataSource(filePath)
|
|
33
|
+
}
|
|
34
|
+
retriever
|
|
35
|
+
} catch (e: Exception) {
|
|
36
|
+
Log.e(TAG, "Error setting data source", e)
|
|
37
|
+
try {
|
|
38
|
+
retriever.release()
|
|
39
|
+
} catch (ee: Exception) {
|
|
40
|
+
Log.e(TAG, "Error releasing retriever", ee)
|
|
41
|
+
}
|
|
42
|
+
null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fun checkFileValidity(urlString: String, callback: FileValidityCallback) {
|
|
47
|
+
Thread {
|
|
48
|
+
val retriever = getMediaMetadataRetriever(urlString)
|
|
49
|
+
if (retriever == null) {
|
|
50
|
+
callback.onResult(false, "unknown", -1L)
|
|
51
|
+
return@Thread
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
val durationStr = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
|
55
|
+
val duration = durationStr?.toLongOrNull() ?: -1L
|
|
56
|
+
|
|
57
|
+
var isValid = false
|
|
58
|
+
var fileType = "unknown"
|
|
59
|
+
|
|
60
|
+
val hasVideo = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO)
|
|
61
|
+
if (hasVideo == "yes") {
|
|
62
|
+
fileType = "video"
|
|
63
|
+
isValid = true
|
|
64
|
+
} else {
|
|
65
|
+
val hasAudio = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO)
|
|
66
|
+
if (hasAudio == "yes") {
|
|
67
|
+
fileType = "audio"
|
|
68
|
+
isValid = true
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
retriever.release()
|
|
74
|
+
} catch (e: IOException) {
|
|
75
|
+
Log.e(TAG, "Error releasing retriever", e)
|
|
76
|
+
}
|
|
77
|
+
callback.onResult(isValid, fileType, if (isValid) duration else -1L)
|
|
78
|
+
}.start()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fun interface FileValidityCallback {
|
|
82
|
+
fun onResult(isValid: Boolean, fileType: String, duration: Long)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
package com.videotrim.utils
|
|
2
|
+
|
|
3
|
+
import android.content.ContentValues
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.media.MediaScannerConnection
|
|
6
|
+
import android.net.Uri
|
|
7
|
+
import android.os.Build
|
|
8
|
+
import android.os.Environment
|
|
9
|
+
import android.provider.MediaStore
|
|
10
|
+
import android.text.TextUtils
|
|
11
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
12
|
+
import java.io.File
|
|
13
|
+
import java.io.FileInputStream
|
|
14
|
+
import java.io.FileOutputStream
|
|
15
|
+
import java.io.IOException
|
|
16
|
+
import java.io.InputStream
|
|
17
|
+
import java.io.OutputStream
|
|
18
|
+
|
|
19
|
+
object StorageUtil {
|
|
20
|
+
|
|
21
|
+
fun getOutputPath(context: Context, outputExt: String): String {
|
|
22
|
+
val timestamp = System.currentTimeMillis() / 1000
|
|
23
|
+
val file = File(context.filesDir, "${VideoTrimmerUtil.FILE_PREFIX}_${timestamp}.$outputExt")
|
|
24
|
+
return file.absolutePath
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
fun isFileExists(filePath: String?): Boolean {
|
|
28
|
+
if (TextUtils.isEmpty(filePath)) return false
|
|
29
|
+
return File(filePath!!).exists()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fun listFiles(context: Context): Array<String> {
|
|
33
|
+
val filesDir = context.filesDir
|
|
34
|
+
val files = filesDir.listFiles { _, name -> name.startsWith(VideoTrimmerUtil.FILE_PREFIX) }
|
|
35
|
+
|
|
36
|
+
return files?.map { it.absolutePath }?.toTypedArray() ?: emptyArray()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
fun deleteFile(path: String?): Boolean {
|
|
40
|
+
if (TextUtils.isEmpty(path)) return true
|
|
41
|
+
return deleteFile(File(path!!))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fun deleteFile(file: File?): Boolean {
|
|
45
|
+
if (file == null || !file.exists()) return true
|
|
46
|
+
|
|
47
|
+
if (file.isFile) return file.delete()
|
|
48
|
+
|
|
49
|
+
if (!file.isDirectory) return false
|
|
50
|
+
|
|
51
|
+
file.listFiles()?.forEach { f ->
|
|
52
|
+
if (f.isFile) {
|
|
53
|
+
f.delete()
|
|
54
|
+
} else if (f.isDirectory) {
|
|
55
|
+
deleteFile(f)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return file.delete()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@Throws(IOException::class)
|
|
62
|
+
fun saveVideoToGallery(context: ReactApplicationContext, videoFilePath: String?) {
|
|
63
|
+
val videoFile = File(videoFilePath!!)
|
|
64
|
+
|
|
65
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
66
|
+
saveVideoUsingMediaStore(context, videoFile)
|
|
67
|
+
} else {
|
|
68
|
+
try {
|
|
69
|
+
saveVideoUsingTraditionalStorage(context, videoFile)
|
|
70
|
+
} catch (e: IOException) {
|
|
71
|
+
throw RuntimeException(e)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private fun saveVideoUsingMediaStore(context: Context, videoFile: File) {
|
|
77
|
+
val values = ContentValues().apply {
|
|
78
|
+
put(MediaStore.Video.Media.TITLE, "My Video Title")
|
|
79
|
+
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
|
|
80
|
+
put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
|
|
81
|
+
}
|
|
82
|
+
val uri: Uri? = context.contentResolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)
|
|
83
|
+
|
|
84
|
+
if (uri != null) {
|
|
85
|
+
try {
|
|
86
|
+
val outputStream = context.contentResolver.openOutputStream(uri)
|
|
87
|
+
copyFile(videoFile, outputStream!!)
|
|
88
|
+
MediaScannerConnection.scanFile(context, arrayOf(uri.toString()), arrayOf("video/*"), null)
|
|
89
|
+
} catch (e: IOException) {
|
|
90
|
+
e.printStackTrace()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@Throws(IOException::class)
|
|
96
|
+
private fun saveVideoUsingTraditionalStorage(context: Context, videoFile: File) {
|
|
97
|
+
val galleryDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)
|
|
98
|
+
val destinationFile = File(galleryDirectory, videoFile.name)
|
|
99
|
+
copyFile(videoFile, destinationFile)
|
|
100
|
+
MediaScannerConnection.scanFile(context, arrayOf(destinationFile.absolutePath), arrayOf("video/*"), null)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@Throws(IOException::class)
|
|
104
|
+
private fun copyFile(sourceFile: File, outputStream: OutputStream) {
|
|
105
|
+
val inputStream: InputStream = FileInputStream(sourceFile)
|
|
106
|
+
copyFile(inputStream, outputStream)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@Throws(IOException::class)
|
|
110
|
+
private fun copyFile(sourceFile: File, destFile: File) {
|
|
111
|
+
val inputStream: InputStream = FileInputStream(sourceFile)
|
|
112
|
+
val outputStream: OutputStream = FileOutputStream(destFile)
|
|
113
|
+
copyFile(inputStream, outputStream)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@Throws(IOException::class)
|
|
117
|
+
private fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
|
|
118
|
+
try {
|
|
119
|
+
val buffer = ByteArray(1024)
|
|
120
|
+
var length: Int
|
|
121
|
+
while (inputStream.read(buffer).also { length = it } > 0) {
|
|
122
|
+
outputStream.write(buffer, 0, length)
|
|
123
|
+
}
|
|
124
|
+
} finally {
|
|
125
|
+
inputStream.close()
|
|
126
|
+
outputStream.close()
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
package com.videotrim.utils
|
|
2
|
+
|
|
3
|
+
import android.annotation.SuppressLint
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.media.MediaMetadataRetriever
|
|
6
|
+
import android.util.Log
|
|
7
|
+
import com.arthenica.ffmpegkit.FFmpegKit
|
|
8
|
+
import com.arthenica.ffmpegkit.FFmpegSession
|
|
9
|
+
import com.arthenica.ffmpegkit.ReturnCode
|
|
10
|
+
import com.facebook.react.bridge.Arguments
|
|
11
|
+
import com.videotrim.enums.ErrorCode
|
|
12
|
+
import com.videotrim.interfaces.VideoTrimListener
|
|
13
|
+
import iknow.android.utils.DeviceUtil
|
|
14
|
+
import iknow.android.utils.UnitConverter
|
|
15
|
+
import iknow.android.utils.callback.SingleCallback
|
|
16
|
+
import iknow.android.utils.thread.BackgroundExecutor
|
|
17
|
+
import java.text.SimpleDateFormat
|
|
18
|
+
import java.util.Date
|
|
19
|
+
import java.util.TimeZone
|
|
20
|
+
|
|
21
|
+
object VideoTrimmerUtil {
|
|
22
|
+
|
|
23
|
+
private val TAG: String = VideoTrimmerUtil::class.java.simpleName
|
|
24
|
+
const val FILE_PREFIX = "trimmedVideo"
|
|
25
|
+
const val MIN_SHOOT_DURATION = 1000L
|
|
26
|
+
const val VIDEO_MAX_TIME = 10
|
|
27
|
+
const val MAX_SHOOT_DURATION = VIDEO_MAX_TIME * 1000L
|
|
28
|
+
@JvmField var MAX_COUNT_RANGE = 10
|
|
29
|
+
@JvmField var SCREEN_WIDTH_FULL = DeviceUtil.getDeviceWidth()
|
|
30
|
+
@JvmField val RECYCLER_VIEW_PADDING = UnitConverter.dpToPx(35)
|
|
31
|
+
const val DEFAULT_AUDIO_EXTENSION = ".wav"
|
|
32
|
+
@JvmField var VIDEO_FRAMES_WIDTH = SCREEN_WIDTH_FULL - RECYCLER_VIEW_PADDING * 2
|
|
33
|
+
@JvmField var mThumbWidth = 0
|
|
34
|
+
@JvmField val THUMB_HEIGHT = UnitConverter.dpToPx(50)
|
|
35
|
+
@JvmField val THUMB_WIDTH = UnitConverter.dpToPx(25)
|
|
36
|
+
private const val THUMB_RESOLUTION_RES = 2
|
|
37
|
+
|
|
38
|
+
fun trim(
|
|
39
|
+
inputFile: String,
|
|
40
|
+
outputFile: String,
|
|
41
|
+
videoDuration: Int,
|
|
42
|
+
startMs: Long,
|
|
43
|
+
endMs: Long,
|
|
44
|
+
enableRotation: Boolean,
|
|
45
|
+
rotationAngle: Double,
|
|
46
|
+
callback: VideoTrimListener
|
|
47
|
+
): FFmpegSession {
|
|
48
|
+
val currentDate = Date()
|
|
49
|
+
@SuppressLint("SimpleDateFormat")
|
|
50
|
+
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ")
|
|
51
|
+
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
|
|
52
|
+
val formattedDateTime = dateFormat.format(currentDate)
|
|
53
|
+
|
|
54
|
+
val cmds = mutableListOf<String>()
|
|
55
|
+
cmds.add("-ss")
|
|
56
|
+
cmds.add("${startMs}ms")
|
|
57
|
+
cmds.add("-to")
|
|
58
|
+
cmds.add("${endMs}ms")
|
|
59
|
+
|
|
60
|
+
if (enableRotation) {
|
|
61
|
+
cmds.add("-display_rotation")
|
|
62
|
+
cmds.add(rotationAngle.toString())
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
cmds.add("-i")
|
|
66
|
+
cmds.add(inputFile)
|
|
67
|
+
cmds.add("-c")
|
|
68
|
+
cmds.add("copy")
|
|
69
|
+
cmds.add("-metadata")
|
|
70
|
+
cmds.add("creation_time=$formattedDateTime")
|
|
71
|
+
cmds.add(outputFile)
|
|
72
|
+
|
|
73
|
+
val command = cmds.toTypedArray()
|
|
74
|
+
val cmdStr = "Command: ${command.joinToString(" ")}"
|
|
75
|
+
|
|
76
|
+
Log.d(TAG, cmdStr)
|
|
77
|
+
|
|
78
|
+
val m = Arguments.createMap()
|
|
79
|
+
m.putString("message", cmdStr)
|
|
80
|
+
callback.onLog(m)
|
|
81
|
+
|
|
82
|
+
return FFmpegKit.executeWithArgumentsAsync(command, { session ->
|
|
83
|
+
val state = session.state
|
|
84
|
+
val returnCode = session.returnCode
|
|
85
|
+
when {
|
|
86
|
+
ReturnCode.isSuccess(session.returnCode) -> {
|
|
87
|
+
callback.onFinishTrim(outputFile, startMs, endMs, videoDuration)
|
|
88
|
+
}
|
|
89
|
+
ReturnCode.isCancel(session.returnCode) -> {
|
|
90
|
+
callback.onCancelTrim()
|
|
91
|
+
}
|
|
92
|
+
else -> {
|
|
93
|
+
val errorMessage = "Command failed with state $state and rc $returnCode.${session.failStackTrace}"
|
|
94
|
+
callback.onError(errorMessage, ErrorCode.TRIMMING_FAILED)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}, { log ->
|
|
98
|
+
Log.d(TAG, "FFmpeg process started with log ${log.message}")
|
|
99
|
+
|
|
100
|
+
val map = Arguments.createMap()
|
|
101
|
+
map.putInt("level", log.level.value)
|
|
102
|
+
map.putString("message", log.message)
|
|
103
|
+
map.putDouble("sessionId", log.sessionId.toDouble())
|
|
104
|
+
map.putString("logStr", log.toString())
|
|
105
|
+
callback.onLog(map)
|
|
106
|
+
}, { statistics ->
|
|
107
|
+
val timeInMilliseconds = statistics.time.toInt()
|
|
108
|
+
if (timeInMilliseconds > 0) {
|
|
109
|
+
val completePercentage = (timeInMilliseconds * 100) / videoDuration
|
|
110
|
+
callback.onTrimmingProgress(completePercentage.coerceIn(0, 100))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
val map = Arguments.createMap()
|
|
114
|
+
map.putDouble("sessionId", statistics.sessionId.toDouble())
|
|
115
|
+
map.putInt("videoFrameNumber", statistics.videoFrameNumber)
|
|
116
|
+
map.putDouble("videoFps", statistics.videoFps.toDouble())
|
|
117
|
+
map.putDouble("videoQuality", statistics.videoQuality.toDouble())
|
|
118
|
+
map.putDouble("size", statistics.size.toDouble())
|
|
119
|
+
map.putDouble("time", statistics.time)
|
|
120
|
+
map.putDouble("bitrate", statistics.bitrate.toDouble())
|
|
121
|
+
map.putDouble("speed", statistics.speed.toDouble())
|
|
122
|
+
map.putString("statisticsStr", statistics.toString())
|
|
123
|
+
callback.onStatistics(map)
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
fun shootVideoThumbInBackground(
|
|
128
|
+
mediaMetadataRetriever: MediaMetadataRetriever,
|
|
129
|
+
totalThumbsCount: Int,
|
|
130
|
+
startPosition: Long,
|
|
131
|
+
endPosition: Long,
|
|
132
|
+
callback: SingleCallback<Bitmap, Int>
|
|
133
|
+
) {
|
|
134
|
+
BackgroundExecutor.execute(object : BackgroundExecutor.Task("", 0L, "") {
|
|
135
|
+
override fun execute() {
|
|
136
|
+
try {
|
|
137
|
+
val interval = (endPosition - startPosition) / (totalThumbsCount - 1)
|
|
138
|
+
for (i in 0 until totalThumbsCount) {
|
|
139
|
+
val frameTime = startPosition + interval * i
|
|
140
|
+
|
|
141
|
+
val bitmap: Bitmap? = try {
|
|
142
|
+
mediaMetadataRetriever.getFrameAtTime(frameTime * 1000, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
|
|
143
|
+
} catch (t: Throwable) {
|
|
144
|
+
t.printStackTrace()
|
|
145
|
+
break
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (bitmap == null) continue
|
|
149
|
+
val scaledBitmap = try {
|
|
150
|
+
Bitmap.createScaledBitmap(bitmap, mThumbWidth * THUMB_RESOLUTION_RES, THUMB_HEIGHT * THUMB_RESOLUTION_RES, false)
|
|
151
|
+
} catch (t: Throwable) {
|
|
152
|
+
t.printStackTrace()
|
|
153
|
+
bitmap
|
|
154
|
+
}
|
|
155
|
+
callback.onSingleCallback(scaledBitmap, interval.toInt())
|
|
156
|
+
}
|
|
157
|
+
} catch (e: Throwable) {
|
|
158
|
+
Thread.getDefaultUncaughtExceptionHandler()?.uncaughtException(Thread.currentThread(), e)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
}
|