@technotoil/image-video-editor 0.1.1 → 0.1.2

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 (35) hide show
  1. package/README.md +17 -3
  2. package/android/src/main/java/com/technotoil/image_videoeditor/FrameGrabberModule.kt +2 -6
  3. package/android/src/main/java/com/technotoil/image_videoeditor/MediaEditorModule.kt +75 -35
  4. package/android/src/main/java/com/technotoil/image_videoeditor/MediaLibraryModule.kt +51 -35
  5. package/android/src/main/java/com/technotoil/image_videoeditor/RNVideoPreviewManager.kt +98 -117
  6. package/ios/RNMediaEditor.m +38 -7
  7. package/ios/RNMediaLibrary.m +19 -15
  8. package/ios/RNVideoPreviewManager.m +2 -0
  9. package/lib/commonjs/components/VideoEditor.js +100 -32
  10. package/lib/commonjs/components/VideoEditor.js.map +1 -1
  11. package/lib/commonjs/screens/CropScreen.js +5 -3
  12. package/lib/commonjs/screens/CropScreen.js.map +1 -1
  13. package/lib/commonjs/screens/EditorScreen.js +229 -44
  14. package/lib/commonjs/screens/EditorScreen.js.map +1 -1
  15. package/lib/commonjs/screens/PickScreen.js +214 -122
  16. package/lib/commonjs/screens/PickScreen.js.map +1 -1
  17. package/lib/module/components/VideoEditor.js +100 -33
  18. package/lib/module/components/VideoEditor.js.map +1 -1
  19. package/lib/module/screens/CropScreen.js +5 -3
  20. package/lib/module/screens/CropScreen.js.map +1 -1
  21. package/lib/module/screens/EditorScreen.js +229 -44
  22. package/lib/module/screens/EditorScreen.js.map +1 -1
  23. package/lib/module/screens/PickScreen.js +215 -123
  24. package/lib/module/screens/PickScreen.js.map +1 -1
  25. package/lib/typescript/src/components/VideoEditor.d.ts +10 -2
  26. package/lib/typescript/src/screens/CropScreen.d.ts +2 -1
  27. package/lib/typescript/src/screens/EditorScreen.d.ts +2 -1
  28. package/lib/typescript/src/screens/PickScreen.d.ts +4 -1
  29. package/lib/typescript/src/types.d.ts +1 -0
  30. package/package.json +4 -1
  31. package/src/components/VideoEditor.tsx +68 -11
  32. package/src/screens/CropScreen.tsx +8 -3
  33. package/src/screens/EditorScreen.tsx +227 -61
  34. package/src/screens/PickScreen.tsx +197 -119
  35. package/src/types.ts +1 -0
package/README.md CHANGED
@@ -6,9 +6,9 @@ A high-performance, feature-rich React Native image and video editor. This libra
6
6
  * 📹 **Native Camera View**: High-performance thread-safe AVCaptureSession wrapper for iOS and custom camera on Android.
7
7
  * ✂️ **Video Trimming**: Seamless interactive trimming of video files.
8
8
  * 🖼️ **Custom Photo Frames**: Layer beautiful custom frames on top of images.
9
- * 🎵 **Audio Mixing**: Select and mix audio tracks onto video exports.
10
- * 🎨 **Real-time Filters**: High-performance shaders/filters for photo and video formats.
9
+ * 🎵 **Audio Mixing & Carousel Sync**: Add background music tracks to your videos. Applies custom audio seamlessly across all videos in a multi-item carousel, automatically pausing original video sound.
11
10
  * 📦 **Modular Export**: Triggers callbacks with local filesystem URIs suitable for server uploads.
11
+ * 📱 **Multi-Select & Swiping**: Select up to 5 items simultaneously and edit them smoothly via horizontal swiping. UI state and trimming configurations perfectly sync across items.
12
12
 
13
13
  ---
14
14
 
@@ -44,11 +44,17 @@ Ensure the following keys are added to your `Info.plist`:
44
44
  <string>We need access to your microphone to record audio for videos.</string>
45
45
  <key>NSPhotoLibraryUsageDescription</key>
46
46
  <string>We need access to your photo library to pick and save media files.</string>
47
+ <key>UIAppFonts</key>
48
+ <array>
49
+ <string>Ionicons.ttf</string>
50
+ </array>
47
51
  ```
48
52
 
49
53
  #### Android Installation
50
- Make sure to add the FFmpeg kit dependency in your app's `android/app/build.gradle`:
54
+ Make sure to add the FFmpeg kit dependency in your app's `android/app/build.gradle`. You will also need to link the fonts from `react-native-vector-icons`:
51
55
  ```groovy
56
+ apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
57
+
52
58
  dependencies {
53
59
  implementation("io.github.maitrungduc1410:ffmpeg-kit-min:6.0.1")
54
60
  }
@@ -92,6 +98,9 @@ export default function App() {
92
98
  headerTitle="Create New Post"
93
99
  cameraModes={['POST', 'STORY', 'REEL']}
94
100
  defaultCameraMode="REEL"
101
+ mediaType="any"
102
+ mediaTabs={['GALLERY', 'PHOTO', 'VIDEO']}
103
+ maxVideoDurationMs={30000} // Force trim videos longer than 30s
95
104
  onCancelPress={() => setEditorVisible(false)}
96
105
  onFinishExport={(editedMedia, paths, editedArray, cameraMode) => {
97
106
  console.log('Export completed successfully!');
@@ -126,6 +135,11 @@ const styles = StyleSheet.create({
126
135
  | `cameraModes` | `Array<'POST' \| 'STORY' \| 'REEL'>` | `['POST', 'STORY', 'REEL']` | Active shooting modes allowed. |
127
136
  | `defaultCameraMode` | `'POST' \| 'STORY' \| 'REEL'` | `"REEL"` | Initial shooting mode when opening. |
128
137
  | `musicList` | `MusicTrack[]` | `[]` | List of audio tracks available to overlay on videos. |
138
+ | `maxSelection` | `number` | `1` | Maximum number of media items user can select. Max allowed is 5. |
139
+ | `aspectRatio` | `'1:1' \| '4:3' \| '4:5' \| '16:9' \| '9:16' \| 'free'` | `'free'` | Enforce a fixed aspect ratio for image/video preview. |
140
+ | `mediaType` | `'photo' \| 'video' \| 'any'` | `'any'` | Filter the library to only show photos, videos, or both. |
141
+ | `mediaTabs` | `Array<'GALLERY' \| 'PHOTO' \| 'VIDEO'>` | `['GALLERY', 'PHOTO', 'VIDEO']` | Control which selection tabs are visible at the bottom of the picker screen. Defaults to the first array item. |
142
+ | `maxVideoDurationMs` | `number` | `undefined` | Maximum video duration allowed in milliseconds. If a video exceeds this, the trim editor will automatically force the user to trim. |
129
143
  | `onCancelPress` | `() => void` | `undefined` | Callback fired when user cancels or leaves the editor. |
130
144
  | `onFinishExport` | `(editedMedia: any, paths: string[], editedArray: any[], cameraMode: string) => void` | `undefined` | Fired when edits finish exporting. Fills `paths` with target video/image file URIs. |
131
145
 
@@ -34,15 +34,11 @@ class FrameGrabberModule(private val reactContext: ReactApplicationContext) :
34
34
 
35
35
  var bitmap: Bitmap? = null
36
36
  try {
37
- bitmap = retriever.getFrameAtTime(timeUs, MediaMetadataRetriever.OPTION_CLOSEST)
37
+ bitmap = retriever.getFrameAtTime(timeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
38
38
  } catch (e: Exception) {
39
- // Fallback
40
- }
41
-
42
- if (bitmap == null) {
43
39
  try {
44
40
  bitmap = retriever.getFrameAtTime(timeUs)
45
- } catch (e: Exception) {
41
+ } catch (e2: Exception) {
46
42
  // Fallback
47
43
  }
48
44
  }
@@ -38,11 +38,11 @@ class MediaEditorModule(private val reactContext: ReactApplicationContext) :
38
38
  return uri
39
39
  }
40
40
 
41
- private fun downloadToCache(urlString: String): File {
41
+ private fun downloadToCache(urlString: String, prefix: String = "music_", ext: String = ".mp3"): File {
42
42
  val url = java.net.URL(urlString)
43
43
  val connection = url.openConnection()
44
44
  connection.connect()
45
- val cacheFile = File.createTempFile("music_", ".mp3", reactContext.cacheDir)
45
+ val cacheFile = File.createTempFile(prefix, ext, reactContext.cacheDir)
46
46
  url.openStream().use { input ->
47
47
  FileOutputStream(cacheFile).use { output ->
48
48
  input.copyTo(output)
@@ -158,18 +158,27 @@ class MediaEditorModule(private val reactContext: ReactApplicationContext) :
158
158
  // Frame drawing
159
159
  if (hasFrame) {
160
160
  try {
161
- val assetManager = reactContext.assets
162
- val inputStream = assetManager.open("frames/$rawFrameKey.png")
163
- val frameBitmap = BitmapFactory.decodeStream(inputStream)
164
- inputStream.close()
161
+ val frameUriString = if (options.hasKey("frameUri")) options.getString("frameUri") else null
162
+ var frameFile: File? = null
163
+ if (!frameUriString.isNullOrEmpty()) {
164
+ frameFile = if (frameUriString.startsWith("http://") || frameUriString.startsWith("https://")) {
165
+ downloadToCache(frameUriString, "frame_", ".png")
166
+ } else {
167
+ MediaFileUtils.copyToCache(reactContext, Uri.parse(frameUriString), "frame_")
168
+ }
169
+ }
165
170
 
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")
171
+ if (frameFile != null) {
172
+ val frameBitmap = BitmapFactory.decodeFile(frameFile.absolutePath)
173
+ if (frameBitmap != null) {
174
+ android.util.Log.d("RNMediaEditor", "Frame loaded: $rawFrameKey ${frameBitmap.width}x${frameBitmap.height}")
175
+ val destRect = Rect(0, 0, baseImage.width, baseImage.height)
176
+ baseCanvas.drawBitmap(frameBitmap, null, destRect, null)
177
+ frameBitmap.recycle()
178
+ } else {
179
+ android.util.Log.w("RNMediaEditor", "Frame bitmap decode returned null for: $rawFrameKey")
180
+ }
181
+ try { if (frameFile.exists()) frameFile.delete() } catch (_: Exception) {}
173
182
  }
174
183
  } catch (e: Exception) {
175
184
  android.util.Log.e("RNMediaEditor", "Frame load FAILED for: $rawFrameKey — ${e.message}", e)
@@ -322,14 +331,18 @@ class MediaEditorModule(private val reactContext: ReactApplicationContext) :
322
331
  if (!editedDir.exists()) editedDir.mkdirs()
323
332
  val outFile = File.createTempFile("edited_video_", ".mp4", editedDir)
324
333
 
325
- // Copy frame png from assets into a real file for ffmpeg input
334
+ // Download/copy frame png from uri
326
335
  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)
336
+ val frameUriString = if (options.hasKey("frameUri")) options.getString("frameUri") else null
337
+ if (hasFrame && !frameUriString.isNullOrEmpty()) {
338
+ try {
339
+ frameFile = if (frameUriString!!.startsWith("http://") || frameUriString.startsWith("https://")) {
340
+ downloadToCache(frameUriString, "frame_", ".png")
341
+ } else {
342
+ MediaFileUtils.copyToCache(reactContext, Uri.parse(frameUriString), "frame_")
332
343
  }
344
+ } catch (e: Exception) {
345
+ android.util.Log.e("RNMediaEditor", "Failed to get frame file: ${e.message}")
333
346
  }
334
347
  }
335
348
 
@@ -338,7 +351,7 @@ class MediaEditorModule(private val reactContext: ReactApplicationContext) :
338
351
  if (hasMusic) {
339
352
  try {
340
353
  musicFile = if (musicUri!!.startsWith("http://") || musicUri.startsWith("https://")) {
341
- downloadToCache(musicUri)
354
+ downloadToCache(musicUri, "music_", ".mp3")
342
355
  } else {
343
356
  MediaFileUtils.copyToCache(reactContext, Uri.parse(musicUri), "music")
344
357
  }
@@ -429,10 +442,14 @@ class MediaEditorModule(private val reactContext: ReactApplicationContext) :
429
442
  }
430
443
  }
431
444
 
445
+ val hasVisualFilters = filterSteps.isNotEmpty() || isImage
446
+
432
447
  // 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]"
448
+ if (hasVisualFilters) {
449
+ if (currentLabel != "[vout]") {
450
+ filterSteps.add("${currentLabel}scale=trunc(iw/2)*2:trunc(ih/2)*2[vout]")
451
+ currentLabel = "[vout]"
452
+ }
436
453
  }
437
454
 
438
455
  // Audio configuration
@@ -451,6 +468,8 @@ class MediaEditorModule(private val reactContext: ReactApplicationContext) :
451
468
 
452
469
  val musicInputIndex = if (hasFrame) 2 else 1
453
470
 
471
+ val musicOffsetMs = if (options.hasKey("musicOffsetMs")) options.getDouble("musicOffsetMs") else 0.0
472
+
454
473
  val audioArgsList = if (hasMusic && musicFile != null) {
455
474
  if (hasVideoAudio && !mute) {
456
475
  // Mix audio tracks: [0:a] and [$musicInputIndex:a]
@@ -501,24 +520,45 @@ class MediaEditorModule(private val reactContext: ReactApplicationContext) :
501
520
  }
502
521
 
503
522
  if (hasMusic && musicFile != null) {
523
+ if (musicOffsetMs > 0) {
524
+ cmdList.add("-ss")
525
+ cmdList.add(f(musicOffsetMs / 1000.0))
526
+ }
504
527
  cmdList.add("-i")
505
528
  cmdList.add(musicFile.absolutePath)
506
529
  }
507
530
 
508
- cmdList.add("-filter_complex")
509
- cmdList.add(filterComplex)
510
- cmdList.add("-map")
511
- cmdList.add("[vout]")
531
+ if (filterComplex.isNotEmpty()) {
532
+ cmdList.add("-filter_complex")
533
+ cmdList.add(filterComplex)
534
+ }
535
+
536
+ if (hasVisualFilters) {
537
+ cmdList.add("-map")
538
+ cmdList.add("[vout]")
539
+ } else {
540
+ cmdList.add("-map")
541
+ cmdList.add("0:v")
542
+ }
543
+
512
544
  cmdList.addAll(audioArgsList)
513
545
 
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
546
+ // Force the output duration to match the intended trim duration exactly
547
+ cmdList.add("-t")
548
+ cmdList.add(ttString)
549
+
550
+ if (hasVisualFilters) {
551
+ cmdList.add("-c:v")
552
+ cmdList.add("h264_mediacodec")
553
+ cmdList.add("-b:v")
554
+ cmdList.add("5M")
555
+ cmdList.add("-pix_fmt")
556
+ cmdList.add("yuv420p")
557
+ } else {
558
+ cmdList.add("-c:v")
559
+ cmdList.add("copy")
560
+ }
561
+
522
562
  cmdList.add(outFile.absolutePath)
523
563
 
524
564
  val cmdArray = cmdList.toTypedArray()
@@ -26,11 +26,6 @@ class MediaLibraryModule(private val reactContext: ReactApplicationContext) :
26
26
 
27
27
  @ReactMethod
28
28
  fun requestAccess(promise: Promise) {
29
- val activity = getCurrentActivity()
30
- if (activity == null) {
31
- promise.resolve(false)
32
- return
33
- }
34
29
  val permissions = if (android.os.Build.VERSION.SDK_INT >= 33) {
35
30
  arrayOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO)
36
31
  } else {
@@ -43,6 +38,13 @@ class MediaLibraryModule(private val reactContext: ReactApplicationContext) :
43
38
  promise.resolve(true)
44
39
  return
45
40
  }
41
+
42
+ val activity = getCurrentActivity()
43
+ if (activity == null) {
44
+ promise.resolve(false)
45
+ return
46
+ }
47
+
46
48
  if (activity is com.facebook.react.modules.core.PermissionAwareActivity) {
47
49
  activity.requestPermissions(permissions, 4422,
48
50
  com.facebook.react.modules.core.PermissionListener { _: Int, _: Array<String>, grantResults: IntArray ->
@@ -82,7 +84,7 @@ class MediaLibraryModule(private val reactContext: ReactApplicationContext) :
82
84
  while (cursor.moveToNext()) {
83
85
  val id = cursor.getString(idCol)
84
86
  val name = cursor.getString(nameCol) ?: "Unknown"
85
- if (!seenIds.contains(id)) {
87
+ if (id != null && !seenIds.contains(id)) {
86
88
  val map = Arguments.createMap()
87
89
  map.putString("id", id)
88
90
  map.putString("title", name)
@@ -143,7 +145,7 @@ class MediaLibraryModule(private val reactContext: ReactApplicationContext) :
143
145
  }
144
146
  val id = cursor.getLong(idCol)
145
147
  val mediaType = cursor.getInt(typeCol)
146
- val duration = cursor.getLong(durationCol)
148
+ var duration = cursor.getLong(durationCol)
147
149
 
148
150
  val uri = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) {
149
151
  ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
@@ -151,6 +153,18 @@ class MediaLibraryModule(private val reactContext: ReactApplicationContext) :
151
153
  ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
152
154
  }
153
155
 
156
+ if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO && duration <= 0) {
157
+ try {
158
+ val retriever = android.media.MediaMetadataRetriever()
159
+ retriever.setDataSource(reactContext, uri)
160
+ val durStr = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_DURATION)
161
+ if (durStr != null) {
162
+ duration = durStr.toLong()
163
+ }
164
+ retriever.release()
165
+ } catch (e: Exception) {}
166
+ }
167
+
154
168
  val thumbUri = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) {
155
169
  createVideoThumbnail(uri, id)
156
170
  } else {
@@ -202,42 +216,44 @@ class MediaLibraryModule(private val reactContext: ReactApplicationContext) :
202
216
 
203
217
  @ReactMethod
204
218
  fun exportAsset(localId: String, promise: Promise) {
205
- try {
206
- val id = localId.toLong()
207
- val resolver = reactContext.contentResolver
208
- var uri: Uri? = null
209
-
210
- // Check if it is a video first
211
- val videoUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
212
- var cursor = resolver.query(videoUri, arrayOf(MediaStore.Video.Media._ID), null, null, null)
213
- if (cursor != null) {
214
- if (cursor.moveToFirst()) {
215
- uri = videoUri
216
- }
217
- cursor.close()
218
- }
219
+ Thread {
220
+ try {
221
+ val id = localId.toLong()
222
+ val resolver = reactContext.contentResolver
223
+ var uri: Uri? = null
219
224
 
220
- // If not a video, check if it's an image
221
- if (uri == null) {
222
- val imageUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
223
- cursor = resolver.query(imageUri, arrayOf(MediaStore.Images.Media._ID), null, null, null)
225
+ // Check if it is a video first
226
+ val videoUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
227
+ var cursor = resolver.query(videoUri, arrayOf(MediaStore.Video.Media._ID), null, null, null)
224
228
  if (cursor != null) {
225
229
  if (cursor.moveToFirst()) {
226
- uri = imageUri
230
+ uri = videoUri
227
231
  }
228
232
  cursor.close()
229
233
  }
230
- }
231
234
 
232
- if (uri != null) {
233
- val cacheFile = MediaFileUtils.copyToCache(reactContext, uri, "export")
234
- promise.resolve(Uri.fromFile(cacheFile).toString())
235
- } else {
236
- promise.reject("not_found", "Asset not found in Video or Image MediaStore")
235
+ // If not a video, check if it's an image
236
+ if (uri == null) {
237
+ val imageUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
238
+ cursor = resolver.query(imageUri, arrayOf(MediaStore.Images.Media._ID), null, null, null)
239
+ if (cursor != null) {
240
+ if (cursor.moveToFirst()) {
241
+ uri = imageUri
242
+ }
243
+ cursor.close()
244
+ }
245
+ }
246
+
247
+ if (uri != null) {
248
+ val cacheFile = MediaFileUtils.copyToCache(reactContext, uri, "export")
249
+ promise.resolve(Uri.fromFile(cacheFile).toString())
250
+ } else {
251
+ promise.reject("not_found", "Asset not found in Video or Image MediaStore")
252
+ }
253
+ } catch (e: Exception) {
254
+ promise.reject("export_failed", e.message, e)
237
255
  }
238
- } catch (e: Exception) {
239
- promise.reject("export_failed", e.message, e)
240
- }
256
+ }.start()
241
257
  }
242
258
 
243
259
  @ReactMethod