react-native-video-trim 6.2.2 → 7.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.
Files changed (29) hide show
  1. package/README.md +34 -9
  2. package/android/src/main/java/com/videotrim/BaseVideoTrimModule.kt +146 -119
  3. package/android/src/main/java/com/videotrim/enums/ErrorCode.kt +2 -1
  4. package/android/src/main/java/com/videotrim/utils/MediaMetadataUtil.kt +6 -2
  5. package/android/src/main/java/com/videotrim/utils/StorageUtil.kt +5 -1
  6. package/android/src/main/java/com/videotrim/utils/VideoTrimmerUtil.kt +99 -26
  7. package/android/src/main/java/com/videotrim/widgets/CropOverlayView.kt +293 -0
  8. package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.kt +556 -28
  9. package/android/src/main/res/drawable/arrow_trianglehead_left_and_right_righttriangle_left_righttriangle_right.xml +19 -0
  10. package/android/src/main/res/drawable/arrow_uturn_backward.xml +15 -0
  11. package/android/src/main/res/drawable/arrow_uturn_forward.xml +15 -0
  12. package/android/src/main/res/drawable/crop.xml +15 -0
  13. package/android/src/main/res/drawable/rotate_left.xml +19 -0
  14. package/android/src/main/res/layout/video_trimmer_view.xml +115 -37
  15. package/android/src/main/res/xml/file_paths.xml +1 -1
  16. package/ios/CropOverlayView.swift +285 -0
  17. package/ios/VideoTrim.mm +2 -4
  18. package/ios/VideoTrim.swift +198 -61
  19. package/ios/VideoTrimmer.swift +2 -4
  20. package/ios/VideoTrimmerViewController.swift +478 -56
  21. package/lib/module/NativeVideoTrim.js.map +1 -1
  22. package/lib/module/index.js +1 -2
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/typescript/src/NativeVideoTrim.d.ts +10 -4
  25. package/lib/typescript/src/NativeVideoTrim.d.ts.map +1 -1
  26. package/lib/typescript/src/index.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/NativeVideoTrim.ts +10 -4
  29. package/src/index.tsx +1 -2
@@ -2,12 +2,14 @@ package com.videotrim.utils
2
2
 
3
3
  import android.annotation.SuppressLint
4
4
  import android.graphics.Bitmap
5
+ import android.graphics.RectF
5
6
  import android.media.MediaMetadataRetriever
6
7
  import android.util.Log
7
8
  import com.arthenica.ffmpegkit.FFmpegKit
8
9
  import com.arthenica.ffmpegkit.FFmpegSession
9
10
  import com.arthenica.ffmpegkit.ReturnCode
10
11
  import com.facebook.react.bridge.Arguments
12
+ import com.facebook.react.bridge.UiThreadUtil
11
13
  import com.videotrim.enums.ErrorCode
12
14
  import com.videotrim.interfaces.VideoTrimListener
13
15
  import iknow.android.utils.DeviceUtil
@@ -17,6 +19,7 @@ import iknow.android.utils.thread.BackgroundExecutor
17
19
  import java.text.SimpleDateFormat
18
20
  import java.util.Date
19
21
  import java.util.TimeZone
22
+ import kotlin.math.roundToInt
20
23
 
21
24
  object VideoTrimmerUtil {
22
25
 
@@ -41,8 +44,13 @@ object VideoTrimmerUtil {
41
44
  videoDuration: Int,
42
45
  startMs: Long,
43
46
  endMs: Long,
44
- enableRotation: Boolean,
45
- rotationAngle: Double,
47
+ userRotationCount: Int,
48
+ userIsFlipped: Boolean,
49
+ cropNormalized: RectF?,
50
+ videoWidth: Int,
51
+ videoHeight: Int,
52
+ videoBitrate: Long,
53
+ enablePreciseTrimming: Boolean,
46
54
  callback: VideoTrimListener
47
55
  ): FFmpegSession {
48
56
  val currentDate = Date()
@@ -57,18 +65,75 @@ object VideoTrimmerUtil {
57
65
  cmds.add("-to")
58
66
  cmds.add("${endMs}ms")
59
67
 
60
- if (enableRotation) {
61
- cmds.add("-display_rotation")
62
- cmds.add(rotationAngle.toString())
63
- }
68
+ val hasUserTransform = userRotationCount != 0 || userIsFlipped
69
+ // 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
73
+
74
+ if (needsReEncode) {
75
+ val videoFilters = mutableListOf<String>()
76
+
77
+ when (userRotationCount) {
78
+ 1 -> videoFilters.add("transpose=2")
79
+ 2 -> { videoFilters.add("transpose=2"); videoFilters.add("transpose=2") }
80
+ 3 -> videoFilters.add("transpose=1")
81
+ }
82
+ if (userIsFlipped) {
83
+ videoFilters.add("hflip")
84
+ }
85
+
86
+ // Convert normalized crop rect [0..1] to pixel coordinates in the post-rotation frame.
87
+ if (cropNormalized != null && videoWidth > 0 && videoHeight > 0) {
88
+ val postW: Int
89
+ val postH: Int
90
+ // After 90°/270° rotation the width and height are swapped.
91
+ if (userRotationCount % 2 != 0) {
92
+ postW = videoHeight; postH = videoWidth
93
+ } else {
94
+ postW = videoWidth; postH = videoHeight
95
+ }
96
+ val cx = (cropNormalized.left * postW).roundToInt()
97
+ val cy = (cropNormalized.top * postH).roundToInt()
98
+ var cw = (cropNormalized.width() * postW).roundToInt()
99
+ var ch = (cropNormalized.height() * postH).roundToInt()
100
+ // H.264 requires even dimensions; round down to nearest even number.
101
+ cw = cw and 1.inv()
102
+ ch = ch and 1.inv()
103
+ if (cw > 0 && ch > 0) {
104
+ videoFilters.add("crop=$cw:$ch:$cx:$cy")
105
+ }
106
+ }
107
+
108
+ val filterString = videoFilters.joinToString(",")
109
+ // Preserve source quality by matching the original bitrate. Falls back to 10 Mbps.
110
+ val bitrateStr = if (videoBitrate > 0) "$videoBitrate" else "10M"
64
111
 
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)
112
+ cmds.addAll(listOf("-i", inputFile))
113
+ // When enablePreciseTrimming is the only reason for re-encode (no transforms),
114
+ // videoFilters is empty — skip -vf entirely to avoid FFmpeg error on empty filter.
115
+ if (filterString.isNotEmpty()) {
116
+ cmds.addAll(listOf("-vf", filterString))
117
+ }
118
+ // h264_mediacodec: Android's hardware H.264 encoder — fast and energy-efficient.
119
+ // Note: Android FFmpegKit auto-rotates by default, so no -noautorotate is needed.
120
+ // 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
+ ))
128
+ } else {
129
+ // 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
+ ))
136
+ }
72
137
 
73
138
  val command = cmds.toTypedArray()
74
139
  val cmdStr = "Command: ${command.joinToString(" ")}"
@@ -82,16 +147,18 @@ object VideoTrimmerUtil {
82
147
  return FFmpegKit.executeWithArgumentsAsync(command, { session ->
83
148
  val state = session.state
84
149
  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)
150
+ UiThreadUtil.runOnUiThread {
151
+ when {
152
+ ReturnCode.isSuccess(returnCode) -> {
153
+ callback.onFinishTrim(outputFile, startMs, endMs, videoDuration)
154
+ }
155
+ ReturnCode.isCancel(returnCode) -> {
156
+ callback.onCancelTrim()
157
+ }
158
+ else -> {
159
+ val errorMessage = "Command failed with state $state and rc $returnCode.${session.failStackTrace}"
160
+ callback.onError(errorMessage, ErrorCode.TRIMMING_FAILED)
161
+ }
95
162
  }
96
163
  }
97
164
  }, { log ->
@@ -102,12 +169,16 @@ object VideoTrimmerUtil {
102
169
  map.putString("message", log.message)
103
170
  map.putDouble("sessionId", log.sessionId.toDouble())
104
171
  map.putString("logStr", log.toString())
105
- callback.onLog(map)
172
+ UiThreadUtil.runOnUiThread {
173
+ callback.onLog(map)
174
+ }
106
175
  }, { statistics ->
107
176
  val timeInMilliseconds = statistics.time.toInt()
108
177
  if (timeInMilliseconds > 0) {
109
178
  val completePercentage = (timeInMilliseconds * 100) / videoDuration
110
- callback.onTrimmingProgress(completePercentage.coerceIn(0, 100))
179
+ UiThreadUtil.runOnUiThread {
180
+ callback.onTrimmingProgress(completePercentage.coerceIn(0, 100))
181
+ }
111
182
  }
112
183
 
113
184
  val map = Arguments.createMap()
@@ -120,7 +191,9 @@ object VideoTrimmerUtil {
120
191
  map.putDouble("bitrate", statistics.bitrate.toDouble())
121
192
  map.putDouble("speed", statistics.speed.toDouble())
122
193
  map.putString("statisticsStr", statistics.toString())
123
- callback.onStatistics(map)
194
+ UiThreadUtil.runOnUiThread {
195
+ callback.onStatistics(map)
196
+ }
124
197
  })
125
198
  }
126
199
 
@@ -0,0 +1,293 @@
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.Path
8
+ import android.graphics.RectF
9
+ import android.graphics.Region
10
+ import android.os.Build
11
+ import android.util.AttributeSet
12
+ import android.util.TypedValue
13
+ import android.view.MotionEvent
14
+ import android.view.ScaleGestureDetector
15
+ import android.view.View
16
+ import kotlin.math.abs
17
+ import kotlin.math.max
18
+ import kotlin.math.min
19
+
20
+ class CropOverlayView @JvmOverloads constructor(
21
+ context: Context,
22
+ attrs: AttributeSet? = null,
23
+ defStyleAttr: Int = 0
24
+ ) : View(context, attrs, defStyleAttr) {
25
+
26
+ var cropRect = RectF()
27
+ set(value) {
28
+ field.set(value)
29
+ invalidate()
30
+ }
31
+
32
+ var allowedRect = RectF()
33
+ set(value) {
34
+ field.set(value)
35
+ if (cropRect.isEmpty) {
36
+ cropRect = RectF(value)
37
+ } else {
38
+ val clamped = RectF()
39
+ if (!clamped.setIntersect(cropRect, value) || clamped.width() < minCropSize || clamped.height() < minCropSize) {
40
+ cropRect = RectF(value)
41
+ }
42
+ }
43
+ }
44
+
45
+ var onCropChanged: (() -> Unit)? = null
46
+ var onCropBegan: (() -> Unit)? = null
47
+ var onCropEnded: (() -> Unit)? = null
48
+
49
+ private val minCropSize = dpToPx(60f)
50
+ private val borderWidth = dpToPx(1f)
51
+ private val cornerLength = dpToPx(20f)
52
+ private val cornerWidth = dpToPx(4f)
53
+ private val gridLineWidth = 1f / resources.displayMetrics.density
54
+ private val edgeHitZone = dpToPx(30f)
55
+
56
+ private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
57
+ style = Paint.Style.STROKE
58
+ color = Color.argb(153, 255, 255, 255)
59
+ strokeWidth = borderWidth
60
+ }
61
+ private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
62
+ style = Paint.Style.STROKE
63
+ color = Color.WHITE
64
+ strokeWidth = gridLineWidth
65
+ }
66
+ private val cornerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
67
+ style = Paint.Style.STROKE
68
+ color = Color.WHITE
69
+ strokeWidth = cornerWidth
70
+ strokeCap = Paint.Cap.ROUND
71
+ }
72
+ private val dimmingPaint = Paint().apply {
73
+ color = Color.argb(140, 0, 0, 0)
74
+ style = Paint.Style.FILL
75
+ }
76
+
77
+ private enum class DragEdge {
78
+ TOP, BOTTOM, LEFT, RIGHT,
79
+ TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT,
80
+ MOVE
81
+ }
82
+
83
+ private var activeEdge: DragEdge? = null
84
+ private var dragStartX = 0f
85
+ private var dragStartY = 0f
86
+ private var dragStartRect = RectF()
87
+ private var gestureStarted = false
88
+
89
+ private val scaleDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
90
+ private var pinchStartRect = RectF()
91
+
92
+ override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
93
+ pinchStartRect.set(cropRect)
94
+ if (!gestureStarted) {
95
+ gestureStarted = true
96
+ onCropBegan?.invoke()
97
+ }
98
+ return true
99
+ }
100
+
101
+ override fun onScale(detector: ScaleGestureDetector): Boolean {
102
+ val scale = detector.scaleFactor
103
+ val cx = pinchStartRect.centerX()
104
+ val cy = pinchStartRect.centerY()
105
+ var newW = max(pinchStartRect.width() * scale, minCropSize)
106
+ var newH = max(pinchStartRect.height() * scale, minCropSize)
107
+ newW = min(newW, allowedRect.width())
108
+ newH = min(newH, allowedRect.height())
109
+ val r = RectF(cx - newW / 2, cy - newH / 2, cx + newW / 2, cy + newH / 2)
110
+ clamp(r, isMove = true)
111
+ cropRect = r
112
+ onCropChanged?.invoke()
113
+ return true
114
+ }
115
+
116
+ override fun onScaleEnd(detector: ScaleGestureDetector) {
117
+ if (gestureStarted) {
118
+ gestureStarted = false
119
+ onCropEnded?.invoke()
120
+ }
121
+ }
122
+ })
123
+
124
+ override fun onDraw(canvas: Canvas) {
125
+ super.onDraw(canvas)
126
+ if (cropRect.isEmpty) return
127
+ val cr = cropRect
128
+
129
+ drawDimming(canvas, cr)
130
+
131
+ canvas.drawRect(cr, borderPaint)
132
+
133
+ for (i in 1..2) {
134
+ val x = cr.left + cr.width() * i / 3f
135
+ canvas.drawLine(x, cr.top, x, cr.bottom, gridPaint)
136
+ }
137
+ for (i in 1..2) {
138
+ val y = cr.top + cr.height() * i / 3f
139
+ canvas.drawLine(cr.left, y, cr.right, y, gridPaint)
140
+ }
141
+
142
+ val cl = cornerLength
143
+ val path = Path()
144
+ fun addCorner(sx: Float, sy: Float, cx: Float, cy: Float, ex: Float, ey: Float) {
145
+ path.moveTo(sx, sy); path.lineTo(cx, cy); path.lineTo(ex, ey)
146
+ }
147
+ addCorner(cr.left, cr.top + cl, cr.left, cr.top, cr.left + cl, cr.top)
148
+ addCorner(cr.right - cl, cr.top, cr.right, cr.top, cr.right, cr.top + cl)
149
+ addCorner(cr.left, cr.bottom - cl, cr.left, cr.bottom, cr.left + cl, cr.bottom)
150
+ addCorner(cr.right - cl, cr.bottom, cr.right, cr.bottom, cr.right, cr.bottom - cl)
151
+ canvas.drawPath(path, cornerPaint)
152
+ }
153
+
154
+ private fun drawDimming(canvas: Canvas, cr: RectF) {
155
+ canvas.save()
156
+ val fullPath = Path()
157
+ fullPath.addRect(0f, 0f, width.toFloat(), height.toFloat(), Path.Direction.CW)
158
+ val cropPath = Path()
159
+ cropPath.addRect(cr, Path.Direction.CW)
160
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
161
+ fullPath.op(cropPath, Path.Op.DIFFERENCE)
162
+ canvas.drawPath(fullPath, dimmingPaint)
163
+ } else {
164
+ @Suppress("DEPRECATION")
165
+ canvas.clipPath(cropPath, Region.Op.DIFFERENCE)
166
+ canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), dimmingPaint)
167
+ }
168
+ canvas.restore()
169
+ }
170
+
171
+ override fun onTouchEvent(event: MotionEvent): Boolean {
172
+ if (event.pointerCount > 1) {
173
+ scaleDetector.onTouchEvent(event)
174
+ return true
175
+ }
176
+
177
+ val x = event.x
178
+ val y = event.y
179
+
180
+ when (event.actionMasked) {
181
+ MotionEvent.ACTION_DOWN -> {
182
+ activeEdge = detectEdge(x, y) ?: return false
183
+ dragStartX = x
184
+ dragStartY = y
185
+ dragStartRect.set(cropRect)
186
+ gestureStarted = true
187
+ onCropBegan?.invoke()
188
+ return true
189
+ }
190
+ MotionEvent.ACTION_MOVE -> {
191
+ val edge = activeEdge ?: return false
192
+ val dx = x - dragStartX
193
+ val dy = y - dragStartY
194
+ cropRect = computeNewRect(edge, dx, dy)
195
+ onCropChanged?.invoke()
196
+ return true
197
+ }
198
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
199
+ activeEdge = null
200
+ if (gestureStarted) {
201
+ gestureStarted = false
202
+ onCropEnded?.invoke()
203
+ }
204
+ return true
205
+ }
206
+ }
207
+ return false
208
+ }
209
+
210
+ private fun detectEdge(px: Float, py: Float): DragEdge? {
211
+ val r = cropRect
212
+ val z = edgeHitZone
213
+
214
+ val nearT = abs(py - r.top) < z
215
+ val nearB = abs(py - r.bottom) < z
216
+ val nearL = abs(px - r.left) < z
217
+ val nearR = abs(px - r.right) < z
218
+ val inH = px > r.left - z && px < r.right + z
219
+ val inV = py > r.top - z && py < r.bottom + z
220
+
221
+ if (nearT && nearL) return DragEdge.TOP_LEFT
222
+ if (nearT && nearR) return DragEdge.TOP_RIGHT
223
+ if (nearB && nearL) return DragEdge.BOTTOM_LEFT
224
+ if (nearB && nearR) return DragEdge.BOTTOM_RIGHT
225
+ if (nearT && inH) return DragEdge.TOP
226
+ if (nearB && inH) return DragEdge.BOTTOM
227
+ if (nearL && inV) return DragEdge.LEFT
228
+ if (nearR && inV) return DragEdge.RIGHT
229
+ if (r.contains(px, py)) return DragEdge.MOVE
230
+ return null
231
+ }
232
+
233
+ private fun computeNewRect(edge: DragEdge, dx: Float, dy: Float): RectF {
234
+ val r = RectF(dragStartRect)
235
+
236
+ when (edge) {
237
+ DragEdge.TOP_LEFT -> { r.left += dx; r.top += dy }
238
+ DragEdge.TOP_RIGHT -> { r.right += dx; r.top += dy }
239
+ DragEdge.BOTTOM_LEFT -> { r.left += dx; r.bottom += dy }
240
+ DragEdge.BOTTOM_RIGHT -> { r.right += dx; r.bottom += dy }
241
+ DragEdge.TOP -> r.top += dy
242
+ DragEdge.BOTTOM -> r.bottom += dy
243
+ DragEdge.LEFT -> r.left += dx
244
+ DragEdge.RIGHT -> r.right += dx
245
+ DragEdge.MOVE -> {
246
+ r.offset(dx, dy)
247
+ clamp(r, isMove = true)
248
+ return r
249
+ }
250
+ }
251
+
252
+ if (r.width() < minCropSize) {
253
+ val anchorsRight = edge == DragEdge.LEFT || edge == DragEdge.TOP_LEFT || edge == DragEdge.BOTTOM_LEFT
254
+ if (anchorsRight) r.left = r.right - minCropSize else r.right = r.left + minCropSize
255
+ }
256
+ if (r.height() < minCropSize) {
257
+ val anchorsBottom = edge == DragEdge.TOP || edge == DragEdge.TOP_LEFT || edge == DragEdge.TOP_RIGHT
258
+ if (anchorsBottom) r.top = r.bottom - minCropSize else r.bottom = r.top + minCropSize
259
+ }
260
+
261
+ clamp(r, isMove = false)
262
+ return r
263
+ }
264
+
265
+ private fun clamp(r: RectF, isMove: Boolean) {
266
+ val a = allowedRect
267
+ if (a.isEmpty) return
268
+
269
+ if (isMove) {
270
+ val w = min(r.width(), a.width())
271
+ val h = min(r.height(), a.height())
272
+ r.right = r.left + w
273
+ r.bottom = r.top + h
274
+ if (r.left < a.left) r.offset(a.left - r.left, 0f)
275
+ if (r.top < a.top) r.offset(0f, a.top - r.top)
276
+ if (r.right > a.right) r.offset(a.right - r.right, 0f)
277
+ if (r.bottom > a.bottom) r.offset(0f, a.bottom - r.bottom)
278
+ } else {
279
+ r.left = max(r.left, a.left)
280
+ r.top = max(r.top, a.top)
281
+ if (r.right > a.right) r.right = a.right
282
+ if (r.bottom > a.bottom) r.bottom = a.bottom
283
+ }
284
+ }
285
+
286
+ fun resetCrop() {
287
+ cropRect = RectF(allowedRect)
288
+ }
289
+
290
+ private fun dpToPx(dp: Float): Float {
291
+ return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics)
292
+ }
293
+ }