@technotoil/image-video-editor 0.1.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 (99) hide show
  1. package/ImageVideoEditor.podspec +21 -0
  2. package/README.md +136 -0
  3. package/android/build.gradle +76 -0
  4. package/android/gradle.properties +5 -0
  5. package/android/src/main/AndroidManifest.xml +13 -0
  6. package/android/src/main/java/com/technotoil/image_videoeditor/FrameGrabberModule.kt +67 -0
  7. package/android/src/main/java/com/technotoil/image_videoeditor/MediaEditorModule.kt +548 -0
  8. package/android/src/main/java/com/technotoil/image_videoeditor/MediaFileUtils.kt +29 -0
  9. package/android/src/main/java/com/technotoil/image_videoeditor/MediaLibraryModule.kt +305 -0
  10. package/android/src/main/java/com/technotoil/image_videoeditor/MediaPackage.kt +26 -0
  11. package/android/src/main/java/com/technotoil/image_videoeditor/MediaPickerModule.kt +111 -0
  12. package/android/src/main/java/com/technotoil/image_videoeditor/MediaPlayerModule.kt +34 -0
  13. package/android/src/main/java/com/technotoil/image_videoeditor/RNCameraViewManager.kt +761 -0
  14. package/android/src/main/java/com/technotoil/image_videoeditor/RNVideoPreviewManager.kt +317 -0
  15. package/ios/PrivacyInfo.xcprivacy +38 -0
  16. package/ios/RNCameraViewManager.m +420 -0
  17. package/ios/RNFrameGrabber.m +61 -0
  18. package/ios/RNMediaEditor.m +905 -0
  19. package/ios/RNMediaLibrary.m +389 -0
  20. package/ios/RNMediaPicker.m +144 -0
  21. package/ios/RNMediaPlayer.m +73 -0
  22. package/ios/RNVideoPreviewManager.m +263 -0
  23. package/ios/frames/film_vintage.png +0 -0
  24. package/ios/frames/floral_gold.png +0 -0
  25. package/ios/frames/minimal_double.png +0 -0
  26. package/ios/frames/polaroid_white.png +0 -0
  27. package/ios/frames/watercolor_floral.png +0 -0
  28. package/lib/module/assets/frames/film_vintage.png +0 -0
  29. package/lib/module/assets/frames/floral_gold.png +0 -0
  30. package/lib/module/assets/frames/minimal_double.png +0 -0
  31. package/lib/module/assets/frames/polaroid_white.png +0 -0
  32. package/lib/module/assets/frames/watercolor_floral.png +0 -0
  33. package/lib/module/components/VideoEditor.js +156 -0
  34. package/lib/module/components/VideoEditor.js.map +1 -0
  35. package/lib/module/index.js +4 -0
  36. package/lib/module/index.js.map +1 -0
  37. package/lib/module/native/CameraView.js +104 -0
  38. package/lib/module/native/CameraView.js.map +1 -0
  39. package/lib/module/native/FrameGrabber.js +13 -0
  40. package/lib/module/native/FrameGrabber.js.map +1 -0
  41. package/lib/module/native/MediaEditor.js +19 -0
  42. package/lib/module/native/MediaEditor.js.map +1 -0
  43. package/lib/module/native/MediaLibrary.js +37 -0
  44. package/lib/module/native/MediaLibrary.js.map +1 -0
  45. package/lib/module/native/MediaPicker.js +13 -0
  46. package/lib/module/native/MediaPicker.js.map +1 -0
  47. package/lib/module/native/MediaPlayer.js +13 -0
  48. package/lib/module/native/MediaPlayer.js.map +1 -0
  49. package/lib/module/native/VideoPreview.js +12 -0
  50. package/lib/module/native/VideoPreview.js.map +1 -0
  51. package/lib/module/package.json +1 -0
  52. package/lib/module/screens/CropScreen.js +1211 -0
  53. package/lib/module/screens/CropScreen.js.map +1 -0
  54. package/lib/module/screens/EditorScreen.js +5752 -0
  55. package/lib/module/screens/EditorScreen.js.map +1 -0
  56. package/lib/module/screens/ExportScreen.js +289 -0
  57. package/lib/module/screens/ExportScreen.js.map +1 -0
  58. package/lib/module/screens/GalleryScreen.js +505 -0
  59. package/lib/module/screens/GalleryScreen.js.map +1 -0
  60. package/lib/module/screens/PickScreen.js +1195 -0
  61. package/lib/module/screens/PickScreen.js.map +1 -0
  62. package/lib/module/types.js +2 -0
  63. package/lib/module/types.js.map +1 -0
  64. package/lib/typescript/src/components/VideoEditor.d.ts +13 -0
  65. package/lib/typescript/src/index.d.ts +2 -0
  66. package/lib/typescript/src/native/CameraView.d.ts +23 -0
  67. package/lib/typescript/src/native/FrameGrabber.d.ts +2 -0
  68. package/lib/typescript/src/native/MediaEditor.d.ts +3 -0
  69. package/lib/typescript/src/native/MediaLibrary.d.ts +16 -0
  70. package/lib/typescript/src/native/MediaPicker.d.ts +2 -0
  71. package/lib/typescript/src/native/MediaPlayer.d.ts +1 -0
  72. package/lib/typescript/src/native/VideoPreview.d.ts +19 -0
  73. package/lib/typescript/src/screens/CropScreen.d.ts +9 -0
  74. package/lib/typescript/src/screens/EditorScreen.d.ts +10 -0
  75. package/lib/typescript/src/screens/ExportScreen.d.ts +9 -0
  76. package/lib/typescript/src/screens/GalleryScreen.d.ts +8 -0
  77. package/lib/typescript/src/screens/PickScreen.d.ts +13 -0
  78. package/lib/typescript/src/types.d.ts +58 -0
  79. package/package.json +101 -0
  80. package/src/assets/frames/film_vintage.png +0 -0
  81. package/src/assets/frames/floral_gold.png +0 -0
  82. package/src/assets/frames/minimal_double.png +0 -0
  83. package/src/assets/frames/polaroid_white.png +0 -0
  84. package/src/assets/frames/watercolor_floral.png +0 -0
  85. package/src/components/VideoEditor.tsx +182 -0
  86. package/src/index.tsx +2 -0
  87. package/src/native/CameraView.tsx +95 -0
  88. package/src/native/FrameGrabber.ts +21 -0
  89. package/src/native/MediaEditor.ts +33 -0
  90. package/src/native/MediaLibrary.ts +69 -0
  91. package/src/native/MediaPicker.ts +17 -0
  92. package/src/native/MediaPlayer.ts +16 -0
  93. package/src/native/VideoPreview.tsx +20 -0
  94. package/src/screens/CropScreen.tsx +968 -0
  95. package/src/screens/EditorScreen.tsx +4517 -0
  96. package/src/screens/ExportScreen.tsx +282 -0
  97. package/src/screens/GalleryScreen.tsx +412 -0
  98. package/src/screens/PickScreen.tsx +1094 -0
  99. package/src/types.ts +58 -0
@@ -0,0 +1,548 @@
1
+ package com.technotoil.image_videoeditor
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.BitmapFactory
5
+ import android.graphics.Canvas
6
+ import android.graphics.ColorMatrix
7
+ import android.graphics.ColorMatrixColorFilter
8
+ import android.graphics.Matrix
9
+ import android.graphics.Paint
10
+ import android.graphics.Rect
11
+ import android.graphics.RectF
12
+ import android.graphics.Typeface
13
+ import android.media.MediaMetadataRetriever
14
+ import android.net.Uri
15
+ import com.arthenica.ffmpegkit.FFmpegKit
16
+ import com.arthenica.ffmpegkit.ReturnCode
17
+ import com.facebook.react.bridge.Promise
18
+ import com.facebook.react.bridge.ReactApplicationContext
19
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
20
+ import com.facebook.react.bridge.ReactMethod
21
+ import com.facebook.react.bridge.ReadableMap
22
+ import java.io.File
23
+ import java.io.FileOutputStream
24
+ import java.util.Locale
25
+
26
+ class MediaEditorModule(private val reactContext: ReactApplicationContext) :
27
+ ReactContextBaseJavaModule(reactContext) {
28
+
29
+ override fun getName(): String = "RNMediaEditor"
30
+
31
+ private fun cleanUri(uriString: String): Uri {
32
+ val uri = Uri.parse(uriString)
33
+ if (uri.scheme == "file") {
34
+ val path = uri.path
35
+ // Strip query parameters for local files as they break some native APIs
36
+ return if (path != null) Uri.fromFile(File(path)) else uri
37
+ }
38
+ return uri
39
+ }
40
+
41
+ private fun downloadToCache(urlString: String): File {
42
+ val url = java.net.URL(urlString)
43
+ val connection = url.openConnection()
44
+ connection.connect()
45
+ val cacheFile = File.createTempFile("music_", ".mp3", reactContext.cacheDir)
46
+ url.openStream().use { input ->
47
+ FileOutputStream(cacheFile).use { output ->
48
+ input.copyTo(output)
49
+ }
50
+ }
51
+ return cacheFile
52
+ }
53
+
54
+
55
+ @ReactMethod
56
+ fun editImage(uriString: String, options: ReadableMap, promise: Promise) {
57
+ try {
58
+ val uri = cleanUri(uriString)
59
+ val input = reactContext.contentResolver.openInputStream(uri)
60
+ val original = BitmapFactory.decodeStream(input)
61
+ input?.close()
62
+
63
+ if (original == null) {
64
+ promise.reject("decode_failed", "Could not decode image")
65
+ return
66
+ }
67
+
68
+ var bitmap = original
69
+ try {
70
+ val exifInput = reactContext.contentResolver.openInputStream(uri)
71
+ if (exifInput != null) {
72
+ val exif = android.media.ExifInterface(exifInput)
73
+ val orientation = exif.getAttributeInt(
74
+ android.media.ExifInterface.TAG_ORIENTATION,
75
+ android.media.ExifInterface.ORIENTATION_NORMAL
76
+ )
77
+ exifInput.close()
78
+
79
+ val exMatrix = Matrix()
80
+ when (orientation) {
81
+ android.media.ExifInterface.ORIENTATION_ROTATE_90 -> exMatrix.postRotate(90f)
82
+ android.media.ExifInterface.ORIENTATION_ROTATE_180 -> exMatrix.postRotate(180f)
83
+ android.media.ExifInterface.ORIENTATION_ROTATE_270 -> exMatrix.postRotate(270f)
84
+ android.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> exMatrix.postScale(-1f, 1f)
85
+ android.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> exMatrix.postScale(1f, -1f)
86
+ }
87
+ if (!exMatrix.isIdentity) {
88
+ bitmap = Bitmap.createBitmap(original, 0, 0, original.width, original.height, exMatrix, true)
89
+ }
90
+ }
91
+ } catch (e: Exception) {
92
+ // ignore exif failure
93
+ }
94
+
95
+ val rotateDegrees = if (options.hasKey("rotateDegrees")) options.getInt("rotateDegrees") else 0
96
+ val flipX = options.hasKey("flipX") && options.getBoolean("flipX")
97
+ val flipY = options.hasKey("flipY") && options.getBoolean("flipY")
98
+ val brightness = if (options.hasKey("brightness")) options.getDouble("brightness").toFloat() else 0f
99
+ val contrast = if (options.hasKey("contrast")) options.getDouble("contrast").toFloat() else 1f
100
+ val saturation = if (options.hasKey("saturation")) options.getDouble("saturation").toFloat() else 1f
101
+ val grayscale = options.hasKey("grayscale") && options.getBoolean("grayscale")
102
+ val overlays = if (options.hasKey("overlays")) options.getArray("overlays") else null
103
+ val effect = if (options.hasKey("effect")) options.getString("effect") else null
104
+
105
+ val matrix = Matrix()
106
+ if (flipX) matrix.postScale(-1f, 1f, bitmap.width / 2f, bitmap.height / 2f)
107
+ if (flipY) matrix.postScale(1f, -1f, bitmap.width / 2f, bitmap.height / 2f)
108
+ if (rotateDegrees != 0) matrix.postRotate(rotateDegrees.toFloat(), bitmap.width / 2f, bitmap.height / 2f)
109
+
110
+ if (!matrix.isIdentity) {
111
+ bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
112
+ }
113
+
114
+ var outBitmap = bitmap
115
+ if (options.hasKey("crop")) {
116
+ val crop = options.getMap("crop")
117
+ if (crop != null) {
118
+ val cw = crop.getInt("width")
119
+ val ch = crop.getInt("height")
120
+ val cx = crop.getInt("x")
121
+ val cy = crop.getInt("y")
122
+
123
+ outBitmap = Bitmap.createBitmap(cw, ch, Bitmap.Config.ARGB_8888)
124
+ val canvas = Canvas(outBitmap)
125
+ canvas.drawColor(android.graphics.Color.BLACK)
126
+ // Draw the bitmap relative to the crop origin.
127
+ // If we want to crop at (50, 50), we draw at (-50, -50).
128
+ canvas.drawBitmap(bitmap, (-cx).toFloat(), (-cy).toFloat(), null)
129
+ }
130
+ }
131
+
132
+ val baseImage = if (outBitmap.isMutable) outBitmap else outBitmap.copy(Bitmap.Config.ARGB_8888, true)
133
+ val baseCanvas = Canvas(baseImage)
134
+
135
+ val rawFrameKey = if (options.hasKey("frame")) options.getString("frame") else null
136
+ val hasFrame = !rawFrameKey.isNullOrEmpty()
137
+
138
+ if (hasFrame) {
139
+ val insetScale = if (options.hasKey("frameScale")) options.getDouble("frameScale").toFloat() else 0.88f
140
+ val offsetYRatio = if (options.hasKey("frameOffsetY")) options.getDouble("frameOffsetY").toFloat() else 0.0f
141
+
142
+ // Fill with opaque black first so JPEG compression doesn't produce
143
+ // garbage in the transparent padding areas around the inset photo.
144
+ baseCanvas.drawColor(android.graphics.Color.BLACK)
145
+
146
+ val insetW = outBitmap.width * insetScale
147
+ val insetH = outBitmap.height * insetScale
148
+ val tx = (outBitmap.width - insetW) / 2f
149
+ val ty = (outBitmap.height - insetH) / 2f
150
+ val extraTY = outBitmap.height * offsetYRatio
151
+
152
+ val destRect = RectF(tx, ty + extraTY, tx + insetW, ty + extraTY + insetH)
153
+ baseCanvas.drawBitmap(outBitmap, null, destRect, null)
154
+ } else {
155
+ baseCanvas.drawBitmap(outBitmap, 0f, 0f, null)
156
+ }
157
+
158
+ // Frame drawing
159
+ if (hasFrame) {
160
+ try {
161
+ val assetManager = reactContext.assets
162
+ val inputStream = assetManager.open("frames/$rawFrameKey.png")
163
+ val frameBitmap = BitmapFactory.decodeStream(inputStream)
164
+ inputStream.close()
165
+
166
+ if (frameBitmap != null) {
167
+ android.util.Log.d("RNMediaEditor", "Frame loaded: $rawFrameKey ${frameBitmap.width}x${frameBitmap.height}")
168
+ val destRect = Rect(0, 0, baseImage.width, baseImage.height)
169
+ baseCanvas.drawBitmap(frameBitmap, null, destRect, null)
170
+ frameBitmap.recycle()
171
+ } else {
172
+ android.util.Log.w("RNMediaEditor", "Frame bitmap decode returned null for: $rawFrameKey")
173
+ }
174
+ } catch (e: Exception) {
175
+ android.util.Log.e("RNMediaEditor", "Frame load FAILED for: $rawFrameKey — ${e.message}", e)
176
+ }
177
+ }
178
+
179
+ val colorMatrix = ColorMatrix()
180
+
181
+ // 1. Saturation
182
+ if (grayscale) {
183
+ colorMatrix.setSaturation(0f)
184
+ } else {
185
+ colorMatrix.setSaturation(saturation)
186
+ }
187
+
188
+ // 2. Contrast & Brightness
189
+ // Contrast: c * x + (1-c)*128
190
+ // Brightness: x + b*255
191
+ val c = contrast
192
+ val b = brightness * 255f
193
+ val off = (1f - c) * 128f + b
194
+
195
+ val m = floatArrayOf(
196
+ c, 0f, 0f, 0f, off,
197
+ 0f, c, 0f, 0f, off,
198
+ 0f, 0f, c, 0f, off,
199
+ 0f, 0f, 0f, 1f, 0f
200
+ )
201
+ val temp = ColorMatrix(m)
202
+ colorMatrix.postConcat(temp)
203
+
204
+ val finalBitmap = Bitmap.createBitmap(baseImage.width, baseImage.height, Bitmap.Config.ARGB_8888)
205
+ val canvas = Canvas(finalBitmap)
206
+ val paint = android.graphics.Paint().apply {
207
+ isAntiAlias = true
208
+ colorFilter = ColorMatrixColorFilter(colorMatrix)
209
+ }
210
+ canvas.drawBitmap(baseImage, 0f, 0f, paint)
211
+
212
+ val overlayPaint = android.graphics.Paint().apply { isAntiAlias = true }
213
+
214
+ // Tint color UI replica
215
+ if (options.hasKey("tintColor") && options.hasKey("tintOpacity")) {
216
+ val tintHex = options.getString("tintColor")
217
+ val tintOp = options.getDouble("tintOpacity").toFloat()
218
+ if (tintHex != null && tintOp > 0f) {
219
+ overlayPaint.color = parseColorSafe(tintHex)
220
+ overlayPaint.alpha = (tintOp * 255).toInt()
221
+ canvas.drawRect(0f, 0f, finalBitmap.width.toFloat(), finalBitmap.height.toFloat(), overlayPaint)
222
+ }
223
+ }
224
+
225
+ overlays?.toArrayList()?.forEach { anyOverlay ->
226
+ val o = anyOverlay as? Map<*, *> ?: return@forEach
227
+ val text = o["text"] as? String ?: return@forEach
228
+ val x = (o["x"] as? Double ?: 0.0).toFloat()
229
+ val y = (o["y"] as? Double ?: 0.0).toFloat()
230
+ val color = (o["color"] as? String)?.let { parseColorSafe(it) } ?: android.graphics.Color.WHITE
231
+ val fontSize = (o["fontSize"] as? Double ?: 16.0).toFloat()
232
+ val textPaint = android.graphics.Paint().apply {
233
+ this.color = color
234
+ this.textSize = fontSize
235
+ this.isAntiAlias = true
236
+ this.typeface = android.graphics.Typeface.DEFAULT_BOLD
237
+ }
238
+ canvas.drawText(text, x, y, textPaint)
239
+ }
240
+
241
+ // Special Effects for Images
242
+ if (!effect.isNullOrEmpty()) {
243
+ when (effect) {
244
+ "vignette" -> {
245
+ val radius = Math.sqrt(Math.pow(finalBitmap.width.toDouble(), 2.0) + Math.pow(finalBitmap.height.toDouble(), 2.0)).toFloat() / 1.2f
246
+ val gradient = android.graphics.RadialGradient(
247
+ finalBitmap.width / 2f, finalBitmap.height / 2f, radius,
248
+ intArrayOf(android.graphics.Color.TRANSPARENT, android.graphics.Color.BLACK),
249
+ floatArrayOf(0.5f, 1.0f),
250
+ android.graphics.Shader.TileMode.CLAMP
251
+ )
252
+ val vPaint = android.graphics.Paint()
253
+ vPaint.shader = gradient
254
+ vPaint.alpha = 180
255
+ canvas.drawRect(0f, 0f, finalBitmap.width.toFloat(), finalBitmap.height.toFloat(), vPaint)
256
+ }
257
+ "pixelize" -> {
258
+ // Simple pixelation via scaling
259
+ val pxScale = 10
260
+ val small = Bitmap.createScaledBitmap(finalBitmap, finalBitmap.width / pxScale, finalBitmap.height / pxScale, false)
261
+ val pixelated = Bitmap.createScaledBitmap(small, finalBitmap.width, finalBitmap.height, false)
262
+ canvas.drawBitmap(pixelated, 0f, 0f, null)
263
+ small.recycle()
264
+ pixelated.recycle()
265
+ }
266
+ }
267
+ }
268
+
269
+ val editedDir = File(reactContext.filesDir, "edited_media")
270
+ if (!editedDir.exists()) editedDir.mkdirs()
271
+ val outFile = File.createTempFile("edited_", ".jpg", editedDir)
272
+ FileOutputStream(outFile).use { out ->
273
+ finalBitmap.compress(Bitmap.CompressFormat.JPEG, 92, out)
274
+ }
275
+
276
+ promise.resolve(Uri.fromFile(outFile).toString())
277
+ } catch (e: Exception) {
278
+ promise.reject("edit_failed", e.message, e)
279
+ }
280
+ }
281
+
282
+ private fun parseColorSafe(hex: String): Int {
283
+ return try { android.graphics.Color.parseColor(hex) } catch (e: Exception) { android.graphics.Color.WHITE }
284
+ }
285
+
286
+ @ReactMethod
287
+ fun trimVideo(uriString: String, options: ReadableMap, promise: Promise) {
288
+ try {
289
+ val uri = cleanUri(uriString)
290
+ val inputFile = MediaFileUtils.copyToCache(reactContext, uri, "trim")
291
+
292
+ val isImage = options.hasKey("isImage") && options.getBoolean("isImage")
293
+ val startMs = if (options.hasKey("startMs")) options.getDouble("startMs") else 0.0
294
+ val endMs = if (options.hasKey("endMs")) options.getDouble("endMs") else 10000.0
295
+ val durationMs = if (isImage) 10000.0 else Math.max(0.0, endMs - startMs)
296
+ val mute = options.hasKey("mute") && options.getBoolean("mute")
297
+
298
+ val rotateDegrees = if (options.hasKey("rotateDegrees")) options.getInt("rotateDegrees") else 0
299
+ val flipX = options.hasKey("flipX") && options.getBoolean("flipX")
300
+ val flipY = options.hasKey("flipY") && options.getBoolean("flipY")
301
+ val brightness = if (options.hasKey("brightness")) options.getDouble("brightness") else 0.0
302
+ val contrast = if (options.hasKey("contrast")) options.getDouble("contrast") else 1.0
303
+ val saturation = if (options.hasKey("saturation")) options.getDouble("saturation") else 1.0
304
+ val grayscale = options.hasKey("grayscale") && options.getBoolean("grayscale")
305
+
306
+ val tintColor = if (options.hasKey("tintColor")) options.getString("tintColor") else null
307
+ val tintOpacity = if (options.hasKey("tintOpacity")) options.getDouble("tintOpacity") else 0.0
308
+
309
+ val crop = if (options.hasKey("crop")) options.getMap("crop") else null
310
+ val hasCrop = crop != null && crop.hasKey("width") && crop.hasKey("height")
311
+
312
+ val frameKey = if (options.hasKey("frame")) options.getString("frame") else null
313
+ val hasFrame = !frameKey.isNullOrEmpty()
314
+ val frameScale = if (options.hasKey("frameScale")) options.getDouble("frameScale") else 0.88
315
+ val frameOffsetY = if (options.hasKey("frameOffsetY")) options.getDouble("frameOffsetY") else 0.0
316
+ val effect = if (options.hasKey("effect")) options.getString("effect") else null
317
+
318
+ val musicUri = if (options.hasKey("musicUri")) options.getString("musicUri") else null
319
+ val hasMusic = !musicUri.isNullOrEmpty()
320
+
321
+ val editedDir = File(reactContext.filesDir, "edited_media")
322
+ if (!editedDir.exists()) editedDir.mkdirs()
323
+ val outFile = File.createTempFile("edited_video_", ".mp4", editedDir)
324
+
325
+ // Copy frame png from assets into a real file for ffmpeg input
326
+ var frameFile: File? = null
327
+ if (hasFrame) {
328
+ frameFile = File(reactContext.cacheDir, "frame_${frameKey}_${System.currentTimeMillis()}.png")
329
+ reactContext.assets.open("frames/${frameKey}.png").use { input ->
330
+ FileOutputStream(frameFile).use { output ->
331
+ input.copyTo(output)
332
+ }
333
+ }
334
+ }
335
+
336
+ // Download/copy music file
337
+ var musicFile: File? = null
338
+ if (hasMusic) {
339
+ try {
340
+ musicFile = if (musicUri!!.startsWith("http://") || musicUri.startsWith("https://")) {
341
+ downloadToCache(musicUri)
342
+ } else {
343
+ MediaFileUtils.copyToCache(reactContext, Uri.parse(musicUri), "music")
344
+ }
345
+ } catch (e: Exception) {
346
+ android.util.Log.e("RNMediaEditor", "Failed to download/copy music: ${e.message}")
347
+ }
348
+ }
349
+
350
+ fun f(d: Double): String = String.format(Locale.US, "%.4f", d)
351
+ fun escapePath(path: String): String = path.replace("\"", "\\\"")
352
+
353
+ val filterSteps = mutableListOf<String>()
354
+ var currentLabel = "[0:v]"
355
+
356
+ // Transform: rotate (multiples of 90) + flips
357
+ val xform = mutableListOf<String>()
358
+ when ((rotateDegrees % 360 + 360) % 360) {
359
+ 90 -> xform.add("transpose=1")
360
+ 180 -> { xform.add("hflip"); xform.add("vflip") }
361
+ 270 -> xform.add("transpose=2")
362
+ }
363
+ if (flipX) xform.add("hflip")
364
+ if (flipY) xform.add("vflip")
365
+ if (xform.isNotEmpty()) {
366
+ filterSteps.add("$currentLabel${xform.joinToString(",")}[v1]")
367
+ currentLabel = "[v1]"
368
+ }
369
+
370
+ // Crop
371
+ if (hasCrop) {
372
+ val cx = crop!!.getInt("x")
373
+ val cy = crop.getInt("y")
374
+ val cw = crop.getInt("width")
375
+ val ch = crop.getInt("height")
376
+ filterSteps.add("$currentLabel" + "crop=${cw}:${ch}:${cx}:${cy}[v2]")
377
+ currentLabel = "[v2]"
378
+ }
379
+
380
+ // 3. Frame overlay (scale down video content slightly, then draw frame png on top)
381
+ if (hasFrame && frameFile != null) {
382
+ val insetLabel = f(frameScale.coerceIn(0.1, 1.0))
383
+ val oy = f(frameOffsetY)
384
+ val vScale = "$currentLabel" + "scale=iw*${insetLabel}:ih*${insetLabel},pad=iw/${insetLabel}:ih/${insetLabel}:(ow-iw)/2:(oh-ih)/2+(${oy}*oh):color=black[v_scaled]"
385
+ filterSteps.add(vScale)
386
+ filterSteps.add("[1:v][v_scaled]scale2ref=w=iw:h=ih[frame_ref][v_padded]")
387
+ filterSteps.add("[v_padded][frame_ref]overlay=0:0:format=auto[v_framed]")
388
+ currentLabel = "[v_framed]"
389
+ }
390
+
391
+ // 4. Color adjustments (Apply after frame so the frame is also affected)
392
+ val colorFilters = mutableListOf<String>()
393
+ if (Math.abs(brightness) > 0.0001 || Math.abs(contrast - 1.0) > 0.0001 || Math.abs(saturation - 1.0) > 0.0001) {
394
+ colorFilters.add("eq=brightness=${f(brightness)}:contrast=${f(contrast)}:saturation=${f(saturation)}")
395
+ }
396
+ if (grayscale) {
397
+ colorFilters.add("hue=s=0")
398
+ }
399
+ if (colorFilters.isNotEmpty()) {
400
+ filterSteps.add("$currentLabel${colorFilters.joinToString(",")}[v_colored]")
401
+ currentLabel = "[v_colored]"
402
+ }
403
+
404
+ // 5. Tint overlay
405
+ val shouldTint = !tintColor.isNullOrEmpty() && tintOpacity > 0.001
406
+ if (shouldTint) {
407
+ val safeTint = tintColor!!.trim()
408
+ filterSteps.add("color=c=${safeTint}@${f(tintOpacity)}:size=2x2[tc]")
409
+ filterSteps.add("[tc]$currentLabel" + "scale2ref=w=iw:h=ih[tc2][v_tint_base]")
410
+ filterSteps.add("[v_tint_base][tc2]overlay=0:0:format=auto[v_tinted]")
411
+ currentLabel = "[v_tinted]"
412
+ }
413
+
414
+ // 6. Special Effects
415
+ if (!effect.isNullOrEmpty()) {
416
+ when (effect) {
417
+ "vignette" -> {
418
+ filterSteps.add("$currentLabel" + "vignette=PI/4[v_effect]")
419
+ currentLabel = "[v_effect]"
420
+ }
421
+ "pixelize" -> {
422
+ filterSteps.add("$currentLabel" + "scale=iw/10:ih/10,scale=iw*10:ih*10:flags=neighbor[v_effect]")
423
+ currentLabel = "[v_effect]"
424
+ }
425
+ "grain" -> {
426
+ filterSteps.add("$currentLabel" + "noise=alls=15:allf=t+u[v_effect]")
427
+ currentLabel = "[v_effect]"
428
+ }
429
+ }
430
+ }
431
+
432
+ // Final output label assignment - ensure even dimensions for the encoder
433
+ if (currentLabel != "[vout]") {
434
+ filterSteps.add("${currentLabel}scale=trunc(iw/2)*2:trunc(ih/2)*2[vout]")
435
+ currentLabel = "[vout]"
436
+ }
437
+
438
+ // Audio configuration
439
+ var hasVideoAudio = false
440
+ if (!isImage) {
441
+ try {
442
+ val retriever = MediaMetadataRetriever()
443
+ retriever.setDataSource(reactContext, uri)
444
+ val hasAudioStr = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO)
445
+ hasVideoAudio = "yes" == hasAudioStr
446
+ retriever.release()
447
+ } catch (e: Exception) {
448
+ // ignore
449
+ }
450
+ }
451
+
452
+ val musicInputIndex = if (hasFrame) 2 else 1
453
+
454
+ val audioArgsList = if (hasMusic && musicFile != null) {
455
+ if (hasVideoAudio && !mute) {
456
+ // Mix audio tracks: [0:a] and [$musicInputIndex:a]
457
+ filterSteps.add("[0:a]volume=1.0[a0];[$musicInputIndex:a]volume=0.8[a1];[a0][a1]amix=inputs=2:duration=first:dropout_transition=2[aout]")
458
+ listOf("-map", "[aout]", "-c:a", "aac", "-b:a", "128k")
459
+ } else {
460
+ // Only map music track
461
+ listOf("-map", "$musicInputIndex:a", "-c:a", "aac", "-b:a", "128k")
462
+ }
463
+ } else {
464
+ if (mute) {
465
+ listOf("-an")
466
+ } else {
467
+ listOf("-map", "0:a?", "-c:a", "aac", "-b:a", "128k")
468
+ }
469
+ }
470
+
471
+ val filterComplex = filterSteps.joinToString(";")
472
+
473
+ val ss = (startMs / 1000.0).coerceAtLeast(0.0)
474
+ val tt = (durationMs / 1000.0).coerceAtLeast(0.0)
475
+ val ssString = f(ss)
476
+ val ttString = f(tt)
477
+
478
+ val cmdList = mutableListOf("-y")
479
+
480
+ if (isImage) {
481
+ // Loop the input image for the duration of the output video
482
+ cmdList.add("-loop")
483
+ cmdList.add("1")
484
+ cmdList.add("-t")
485
+ cmdList.add(ttString)
486
+ cmdList.add("-i")
487
+ cmdList.add(inputFile.absolutePath)
488
+ } else {
489
+ // Fast seek on input
490
+ cmdList.add("-ss")
491
+ cmdList.add(ssString)
492
+ cmdList.add("-i")
493
+ cmdList.add(inputFile.absolutePath)
494
+ cmdList.add("-t")
495
+ cmdList.add(ttString)
496
+ }
497
+
498
+ if (frameFile != null) {
499
+ cmdList.add("-i")
500
+ cmdList.add(frameFile.absolutePath)
501
+ }
502
+
503
+ if (hasMusic && musicFile != null) {
504
+ cmdList.add("-i")
505
+ cmdList.add(musicFile.absolutePath)
506
+ }
507
+
508
+ cmdList.add("-filter_complex")
509
+ cmdList.add(filterComplex)
510
+ cmdList.add("-map")
511
+ cmdList.add("[vout]")
512
+ cmdList.addAll(audioArgsList)
513
+
514
+ // Use h264_mediacodec for hardware acceleration on Android.
515
+ // This is efficient and works well with minimal builds.
516
+ cmdList.add("-c:v")
517
+ cmdList.add("h264_mediacodec")
518
+ cmdList.add("-b:v")
519
+ cmdList.add("5M")
520
+ cmdList.add("-pix_fmt")
521
+ cmdList.add("yuv420p") // Wide compatibility
522
+ cmdList.add(outFile.absolutePath)
523
+
524
+ val cmdArray = cmdList.toTypedArray()
525
+ android.util.Log.d("RNMediaEditor", "FFmpeg CMD: ${cmdList.joinToString(" ")}")
526
+
527
+ FFmpegKit.executeWithArgumentsAsync(cmdArray) { session ->
528
+ try {
529
+ if (inputFile.exists()) inputFile.delete()
530
+ frameFile?.let { try { if (it.exists()) it.delete() } catch (_: Exception) {} }
531
+ musicFile?.let { try { if (it.exists()) it.delete() } catch (_: Exception) {} }
532
+ val rc = session.returnCode
533
+ if (ReturnCode.isSuccess(rc)) {
534
+ promise.resolve(Uri.fromFile(outFile).toString())
535
+ } else {
536
+ val logs = session.allLogsAsString ?: "FFmpeg failed"
537
+ promise.reject("trim_failed", logs)
538
+ }
539
+ } catch (e: Exception) {
540
+ promise.reject("trim_failed", e.message, e)
541
+ }
542
+ }
543
+ } catch (e: Exception) {
544
+ promise.reject("trim_failed", e.message, e)
545
+ }
546
+ }
547
+
548
+ }
@@ -0,0 +1,29 @@
1
+ package com.technotoil.image_videoeditor
2
+
3
+ import android.content.ContentResolver
4
+ import android.content.Context
5
+ import android.net.Uri
6
+ import android.webkit.MimeTypeMap
7
+ import java.io.File
8
+ import java.io.FileOutputStream
9
+
10
+ object MediaFileUtils {
11
+ fun copyToCache(context: Context, uri: Uri, prefix: String): File {
12
+ val resolver: ContentResolver = context.contentResolver
13
+ val extension = getExtension(resolver, uri)
14
+ val dest = File.createTempFile(prefix, extension?.let { ".${it}" } ?: "", context.cacheDir)
15
+ resolver.openInputStream(uri).use { input ->
16
+ FileOutputStream(dest).use { output ->
17
+ if (input != null) {
18
+ input.copyTo(output)
19
+ }
20
+ }
21
+ }
22
+ return dest
23
+ }
24
+
25
+ private fun getExtension(resolver: ContentResolver, uri: Uri): String? {
26
+ val type = resolver.getType(uri) ?: return null
27
+ return MimeTypeMap.getSingleton().getExtensionFromMimeType(type)
28
+ }
29
+ }