@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.
- package/ImageVideoEditor.podspec +21 -0
- package/README.md +136 -0
- package/android/build.gradle +76 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +13 -0
- package/android/src/main/java/com/technotoil/image_videoeditor/FrameGrabberModule.kt +67 -0
- package/android/src/main/java/com/technotoil/image_videoeditor/MediaEditorModule.kt +548 -0
- package/android/src/main/java/com/technotoil/image_videoeditor/MediaFileUtils.kt +29 -0
- package/android/src/main/java/com/technotoil/image_videoeditor/MediaLibraryModule.kt +305 -0
- package/android/src/main/java/com/technotoil/image_videoeditor/MediaPackage.kt +26 -0
- package/android/src/main/java/com/technotoil/image_videoeditor/MediaPickerModule.kt +111 -0
- package/android/src/main/java/com/technotoil/image_videoeditor/MediaPlayerModule.kt +34 -0
- package/android/src/main/java/com/technotoil/image_videoeditor/RNCameraViewManager.kt +761 -0
- package/android/src/main/java/com/technotoil/image_videoeditor/RNVideoPreviewManager.kt +317 -0
- package/ios/PrivacyInfo.xcprivacy +38 -0
- package/ios/RNCameraViewManager.m +420 -0
- package/ios/RNFrameGrabber.m +61 -0
- package/ios/RNMediaEditor.m +905 -0
- package/ios/RNMediaLibrary.m +389 -0
- package/ios/RNMediaPicker.m +144 -0
- package/ios/RNMediaPlayer.m +73 -0
- package/ios/RNVideoPreviewManager.m +263 -0
- package/ios/frames/film_vintage.png +0 -0
- package/ios/frames/floral_gold.png +0 -0
- package/ios/frames/minimal_double.png +0 -0
- package/ios/frames/polaroid_white.png +0 -0
- package/ios/frames/watercolor_floral.png +0 -0
- package/lib/module/assets/frames/film_vintage.png +0 -0
- package/lib/module/assets/frames/floral_gold.png +0 -0
- package/lib/module/assets/frames/minimal_double.png +0 -0
- package/lib/module/assets/frames/polaroid_white.png +0 -0
- package/lib/module/assets/frames/watercolor_floral.png +0 -0
- package/lib/module/components/VideoEditor.js +156 -0
- package/lib/module/components/VideoEditor.js.map +1 -0
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/native/CameraView.js +104 -0
- package/lib/module/native/CameraView.js.map +1 -0
- package/lib/module/native/FrameGrabber.js +13 -0
- package/lib/module/native/FrameGrabber.js.map +1 -0
- package/lib/module/native/MediaEditor.js +19 -0
- package/lib/module/native/MediaEditor.js.map +1 -0
- package/lib/module/native/MediaLibrary.js +37 -0
- package/lib/module/native/MediaLibrary.js.map +1 -0
- package/lib/module/native/MediaPicker.js +13 -0
- package/lib/module/native/MediaPicker.js.map +1 -0
- package/lib/module/native/MediaPlayer.js +13 -0
- package/lib/module/native/MediaPlayer.js.map +1 -0
- package/lib/module/native/VideoPreview.js +12 -0
- package/lib/module/native/VideoPreview.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/screens/CropScreen.js +1211 -0
- package/lib/module/screens/CropScreen.js.map +1 -0
- package/lib/module/screens/EditorScreen.js +5752 -0
- package/lib/module/screens/EditorScreen.js.map +1 -0
- package/lib/module/screens/ExportScreen.js +289 -0
- package/lib/module/screens/ExportScreen.js.map +1 -0
- package/lib/module/screens/GalleryScreen.js +505 -0
- package/lib/module/screens/GalleryScreen.js.map +1 -0
- package/lib/module/screens/PickScreen.js +1195 -0
- package/lib/module/screens/PickScreen.js.map +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/src/components/VideoEditor.d.ts +13 -0
- package/lib/typescript/src/index.d.ts +2 -0
- package/lib/typescript/src/native/CameraView.d.ts +23 -0
- package/lib/typescript/src/native/FrameGrabber.d.ts +2 -0
- package/lib/typescript/src/native/MediaEditor.d.ts +3 -0
- package/lib/typescript/src/native/MediaLibrary.d.ts +16 -0
- package/lib/typescript/src/native/MediaPicker.d.ts +2 -0
- package/lib/typescript/src/native/MediaPlayer.d.ts +1 -0
- package/lib/typescript/src/native/VideoPreview.d.ts +19 -0
- package/lib/typescript/src/screens/CropScreen.d.ts +9 -0
- package/lib/typescript/src/screens/EditorScreen.d.ts +10 -0
- package/lib/typescript/src/screens/ExportScreen.d.ts +9 -0
- package/lib/typescript/src/screens/GalleryScreen.d.ts +8 -0
- package/lib/typescript/src/screens/PickScreen.d.ts +13 -0
- package/lib/typescript/src/types.d.ts +58 -0
- package/package.json +101 -0
- package/src/assets/frames/film_vintage.png +0 -0
- package/src/assets/frames/floral_gold.png +0 -0
- package/src/assets/frames/minimal_double.png +0 -0
- package/src/assets/frames/polaroid_white.png +0 -0
- package/src/assets/frames/watercolor_floral.png +0 -0
- package/src/components/VideoEditor.tsx +182 -0
- package/src/index.tsx +2 -0
- package/src/native/CameraView.tsx +95 -0
- package/src/native/FrameGrabber.ts +21 -0
- package/src/native/MediaEditor.ts +33 -0
- package/src/native/MediaLibrary.ts +69 -0
- package/src/native/MediaPicker.ts +17 -0
- package/src/native/MediaPlayer.ts +16 -0
- package/src/native/VideoPreview.tsx +20 -0
- package/src/screens/CropScreen.tsx +968 -0
- package/src/screens/EditorScreen.tsx +4517 -0
- package/src/screens/ExportScreen.tsx +282 -0
- package/src/screens/GalleryScreen.tsx +412 -0
- package/src/screens/PickScreen.tsx +1094 -0
- 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
|
+
}
|