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
@@ -1,5 +1,6 @@
1
1
  import React
2
2
  import Photos
3
+ import AVFoundation
3
4
  import ffmpegkit
4
5
 
5
6
  let FILE_PREFIX = "trimmedVideo"
@@ -49,14 +50,12 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
49
50
  return editorConfig?["removeAfterFailedToShare"] as! Bool
50
51
  }
51
52
  }
52
- private var enableRotation: Bool {
53
- get {
54
- return editorConfig?["enableRotation"] as! Bool
55
- }
56
- }
57
- private var rotationAngle: Double {
53
+
54
+ /// When true, forces re-encoding even when no transforms are applied,
55
+ /// giving frame-accurate trim points instead of keyframe-aligned cuts.
56
+ private var enablePreciseTrimming: Bool {
58
57
  get {
59
- return editorConfig?["rotationAngle"] as! Double
58
+ return editorConfig?["enablePreciseTrimming"] as? Bool ?? false
60
59
  }
61
60
  }
62
61
 
@@ -293,7 +292,7 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
293
292
  dialogMessage.overrideUserInterfaceStyle = .dark
294
293
 
295
294
  // Create OK button with action handler
296
- let ok = UIAlertAction(title: self.cancelDialogConfirmText, style: .destructive, handler: { (action) -> Void in
295
+ let ok = UIAlertAction(title: self.cancelTrimmingDialogConfirmText, style: .destructive, handler: { (action) -> Void in
297
296
 
298
297
  if let ffmpegSession = ffmpegSession {
299
298
  ffmpegSession.cancel()
@@ -307,7 +306,7 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
307
306
  })
308
307
 
309
308
  // Create Cancel button with action handlder
310
- let cancel = UIAlertAction(title: self.cancelDialogCancelText, style: .cancel)
309
+ let cancel = UIAlertAction(title: self.cancelTrimmingDialogCancelText, style: .cancel)
311
310
 
312
311
  //Add OK and Cancel button to an Alert object
313
312
  dialogMessage.addAction(ok)
@@ -343,19 +342,126 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
343
342
  "\(endTime * 1000)ms",
344
343
  ]
345
344
 
346
- if enableRotation {
347
- cmds.append(contentsOf: ["-display_rotation", "\(rotationAngle)"])
345
+ var videoFilters: [String] = []
346
+ let hasUserTransform = vc != nil && (vc!.rotationCount != 0 || vc!.isFlipped)
347
+ let cropNorm = vc?.cropNormalizedRect
348
+ // Re-encode is required when: (1) user applied flip/rotate, (2) user cropped, or
349
+ // (3) enablePreciseTrimming is on. In all three cases, -c copy won't work because
350
+ // either we need video filters or we need frame-accurate cut points.
351
+ let needsReEncode = hasUserTransform || cropNorm != nil || enablePreciseTrimming
352
+
353
+ if needsReEncode, let vc = vc {
354
+ // -noautorotate disables FFmpeg's automatic rotation, so we must manually
355
+ // compensate for the source video's rotation metadata via transpose filters.
356
+ if let asset = vc.asset,
357
+ let videoTrack = asset.tracks(withMediaType: .video).first {
358
+ let t = videoTrack.preferredTransform
359
+ let sourceAngle = atan2(t.b, t.a)
360
+ if abs(sourceAngle - .pi / 2) < 0.1 {
361
+ videoFilters.append("transpose=1")
362
+ } else if abs(sourceAngle + .pi / 2) < 0.1 {
363
+ videoFilters.append("transpose=2")
364
+ } else if abs(abs(sourceAngle) - .pi) < 0.1 {
365
+ videoFilters.append("transpose=1")
366
+ videoFilters.append("transpose=1")
367
+ }
368
+ }
369
+
370
+ switch vc.rotationCount {
371
+ case 1: videoFilters.append("transpose=2")
372
+ case 2:
373
+ videoFilters.append("transpose=2")
374
+ videoFilters.append("transpose=2")
375
+ case 3: videoFilters.append("transpose=1")
376
+ default: break
377
+ }
378
+ if vc.isFlipped {
379
+ videoFilters.append("hflip")
380
+ }
381
+
382
+ if let cn = cropNorm, let asset = vc.asset,
383
+ let track = asset.tracks(withMediaType: .video).first {
384
+ let raw = track.naturalSize
385
+ let pt = track.preferredTransform
386
+ let angle = atan2(pt.b, pt.a)
387
+ let isSrcRotated = abs(angle - .pi / 2) < 0.1 || abs(angle + .pi / 2) < 0.1
388
+ let corrected = isSrcRotated
389
+ ? CGSize(width: raw.height, height: raw.width)
390
+ : raw
391
+
392
+ let postW: CGFloat
393
+ let postH: CGFloat
394
+ if vc.rotationCount % 2 != 0 {
395
+ postW = corrected.height
396
+ postH = corrected.width
397
+ } else {
398
+ postW = corrected.width
399
+ postH = corrected.height
400
+ }
401
+
402
+ let cx = Int(round(cn.origin.x * postW))
403
+ let cy = Int(round(cn.origin.y * postH))
404
+ var cw = Int(round(cn.size.width * postW))
405
+ var ch = Int(round(cn.size.height * postH))
406
+ // H.264 requires even dimensions; round down to nearest even number.
407
+ cw = cw & ~1
408
+ ch = ch & ~1
409
+ if cw > 0 && ch > 0 {
410
+ videoFilters.append("crop=\(cw):\(ch):\(cx):\(cy)")
411
+ }
412
+ }
348
413
  }
349
414
 
350
- cmds.append(contentsOf: [
351
- "-i",
352
- inputFile.path,
353
- "-c",
354
- "copy",
355
- "-metadata",
356
- "creation_time=\(dateTime)",
357
- outputFile!.path
358
- ])
415
+ guard let outputFile = outputFile else {
416
+ self.onError(message: "Output file path is nil", code: .trimmingFailed)
417
+ return
418
+ }
419
+
420
+ if needsReEncode {
421
+ // Preserve source quality by matching the original bitrate. Falls back to 10 Mbps
422
+ // if the track's estimated data rate is unavailable.
423
+ var bitrateStr = "10M"
424
+ if let asset = vc?.asset,
425
+ let videoTrack = asset.tracks(withMediaType: .video).first {
426
+ let bitrate = Int(videoTrack.estimatedDataRate)
427
+ if bitrate > 0 {
428
+ bitrateStr = "\(bitrate)"
429
+ }
430
+ }
431
+
432
+ // -noautorotate: we handle rotation via explicit transpose filters above,
433
+ // so FFmpeg must not auto-rotate or the output will be double-rotated.
434
+ cmds.append("-noautorotate")
435
+ cmds.append(contentsOf: ["-i", inputFile.path])
436
+ // When enablePreciseTrimming is the only reason for re-encode (no transforms),
437
+ // videoFilters is empty — skip -vf entirely to avoid FFmpeg error on empty filter.
438
+ if !videoFilters.isEmpty {
439
+ cmds.append(contentsOf: ["-vf", videoFilters.joined(separator: ",")])
440
+ }
441
+ // h264_videotoolbox: Apple's hardware H.264 encoder — fast and energy-efficient.
442
+ cmds.append(contentsOf: [
443
+ "-c:v",
444
+ "h264_videotoolbox",
445
+ "-b:v",
446
+ bitrateStr,
447
+ "-c:a",
448
+ "copy",
449
+ "-metadata",
450
+ "creation_time=\(dateTime)",
451
+ outputFile.path
452
+ ])
453
+ } else {
454
+ // Stream copy: no re-encoding, extremely fast but only cuts at keyframes.
455
+ cmds.append(contentsOf: [
456
+ "-i",
457
+ inputFile.path,
458
+ "-c",
459
+ "copy",
460
+ "-metadata",
461
+ "creation_time=\(dateTime)",
462
+ outputFile.path
463
+ ])
464
+ }
359
465
 
360
466
  print("Command: ", cmds.joined(separator: " "))
361
467
 
@@ -372,7 +478,7 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
372
478
  var shouldCloseEditor = false
373
479
 
374
480
  if ReturnCode.isSuccess(returnCode) {
375
- let eventPayload: [String: Any] = ["outputPath": self.outputFile!.absoluteString, "startTime": (startTime * 1000).rounded(), "endTime": (endTime * 1000).rounded(), "duration": (videoDuration * 1000).rounded()]
481
+ let eventPayload: [String: Any] = ["outputPath": outputFile.absoluteString, "startTime": (startTime * 1000).rounded(), "endTime": (endTime * 1000).rounded(), "duration": (videoDuration * 1000).rounded()]
376
482
  self.emitEventToJS("onFinishTrimming", eventData: eventPayload)
377
483
 
378
484
  if (self.saveToPhoto && isVideoType) {
@@ -383,19 +489,19 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
383
489
  }
384
490
 
385
491
  PHPhotoLibrary.shared().performChanges({
386
- let request = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: self.outputFile!)
492
+ let request = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: outputFile)
387
493
  request?.creationDate = Date()
388
494
  }) { success, error in
389
495
  if success {
390
496
  print("Edited video saved to Photo Library successfully.")
391
497
 
392
498
  if self.removeAfterSavedToPhoto {
393
- let _ = VideoTrim.deleteFile(url: self.outputFile!)
499
+ let _ = VideoTrim.deleteFile(url: outputFile)
394
500
  }
395
501
  } else {
396
502
  self.onError(message: "Failed to save edited video to Photo Library: \(error?.localizedDescription ?? "Unknown error")", code: .failToSaveToPhoto)
397
503
  if self.removeAfterFailedToSavePhoto {
398
- let _ = VideoTrim.deleteFile(url: self.outputFile!)
504
+ let _ = VideoTrim.deleteFile(url: outputFile)
399
505
  }
400
506
  }
401
507
  }
@@ -404,7 +510,7 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
404
510
  DispatchQueue.main.async {
405
511
  progressAlert.dismiss(animated: true) {
406
512
  self.isTrimming = false
407
- self.saveFileToFilesApp(fileURL: self.outputFile!)
513
+ self.saveFileToFilesApp(fileURL: outputFile)
408
514
  }
409
515
  }
410
516
  return
@@ -412,7 +518,7 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
412
518
  DispatchQueue.main.async {
413
519
  progressAlert.dismiss(animated: true) {
414
520
  self.isTrimming = false
415
- self.shareFile(fileURL: self.outputFile!)
521
+ self.shareFile(fileURL: outputFile)
416
522
  }
417
523
  }
418
524
  return
@@ -479,9 +585,7 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
479
585
  // New Arch
480
586
  @objc(trim:url:config:)
481
587
  public func _trim(inputFile: String, config: NSDictionary, completion: @escaping ([String: Any]) -> Void) {
482
- let destPath = URL(string: inputFile)
483
-
484
- guard let destPath = destPath else {
588
+ guard let destPath = URL(string: inputFile) ?? URL(fileURLWithPath: inputFile) as URL? else {
485
589
  let result = [
486
590
  "success": false,
487
591
  "message": "Invalid input file path",
@@ -512,20 +616,48 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
512
616
  "\(endTime)ms",
513
617
  ]
514
618
 
515
- if let enableRotation = config["enableRotation"] as? Bool, enableRotation {
516
- let rotationAngle = config["rotationAngle"] as? Double ?? 0
517
- cmds.append(contentsOf: ["-display_rotation", "\(rotationAngle)"])
518
- }
619
+ // Headless trim: no editor UI, so no transforms (flip/rotate/crop) are possible.
620
+ // The only reason to re-encode here is enablePreciseTrimming for frame-accurate cuts.
621
+ let enablePrecise = config["enablePreciseTrimming"] as? Bool ?? false
519
622
 
520
- cmds.append(contentsOf: [
521
- "-i",
522
- destPath.path,
523
- "-c",
524
- "copy",
525
- "-metadata",
526
- "creation_time=\(dateTime)",
527
- outputFile.path
528
- ])
623
+ if enablePrecise {
624
+ // Match source bitrate to preserve quality; fall back to 10 Mbps.
625
+ var bitrateStr = "10M"
626
+ let asset = AVURLAsset(url: destPath)
627
+ if let videoTrack = asset.tracks(withMediaType: .video).first {
628
+ let bitrate = Int(videoTrack.estimatedDataRate)
629
+ if bitrate > 0 {
630
+ bitrateStr = "\(bitrate)"
631
+ }
632
+ }
633
+
634
+ // No -noautorotate here: headless trim has no manual rotation filters,
635
+ // so FFmpeg's auto-rotation produces the correct output orientation.
636
+ cmds.append(contentsOf: [
637
+ "-i",
638
+ destPath.path,
639
+ "-c:v",
640
+ "h264_videotoolbox",
641
+ "-b:v",
642
+ bitrateStr,
643
+ "-c:a",
644
+ "copy",
645
+ "-metadata",
646
+ "creation_time=\(dateTime)",
647
+ outputFile.path
648
+ ])
649
+ } else {
650
+ // Stream copy: no re-encoding, extremely fast but only cuts at keyframes.
651
+ cmds.append(contentsOf: [
652
+ "-i",
653
+ destPath.path,
654
+ "-c",
655
+ "copy",
656
+ "-metadata",
657
+ "creation_time=\(dateTime)",
658
+ outputFile.path
659
+ ])
660
+ }
529
661
 
530
662
  print("Command: ", cmds.joined(separator: " "))
531
663
 
@@ -677,21 +809,21 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
677
809
  print(message)
678
810
  self.onError(message: message, code: .failToShare)
679
811
 
680
- if self.removeAfterFailedToShare {
681
- let _ = VideoTrim.deleteFile(url: self.outputFile!)
812
+ if self.removeAfterFailedToShare, let outputFile = self.outputFile {
813
+ let _ = VideoTrim.deleteFile(url: outputFile)
682
814
  }
683
815
  return
684
816
  }
685
817
 
686
818
  if completed {
687
819
  print("User completed the sharing activity")
688
- if self.removeAfterShared {
689
- let _ = VideoTrim.deleteFile(url: self.outputFile!)
820
+ if self.removeAfterShared, let outputFile = self.outputFile {
821
+ let _ = VideoTrim.deleteFile(url: outputFile)
690
822
  }
691
823
  } else {
692
824
  print("User cancelled or failed to complete the sharing activity")
693
- if self.removeAfterFailedToShare {
694
- let _ = VideoTrim.deleteFile(url: self.outputFile!)
825
+ if self.removeAfterFailedToShare, let outputFile = self.outputFile {
826
+ let _ = VideoTrim.deleteFile(url: outputFile)
695
827
  }
696
828
  }
697
829
 
@@ -727,10 +859,8 @@ extension VideoTrim {
727
859
  editorConfig = config
728
860
  print("Show editor called with URI: \(uri)")
729
861
 
730
- let destPath = URL(string: uri)
731
- print("Destination Path: \(destPath!.absoluteString), path: \(destPath!.path)")
732
-
733
- guard let destPath = destPath else { return }
862
+ guard let destPath = URL(string: uri) ?? URL(fileURLWithPath: uri) as URL? else { return }
863
+ print("Destination Path: \(destPath.absoluteString), path: \(destPath.path)")
734
864
 
735
865
  DispatchQueue.main.async {
736
866
  self.vc = VideoTrimmerViewController()
@@ -773,8 +903,9 @@ extension VideoTrim {
773
903
  let isVideoType = (config["type"] as? String ?? "video") == "video"
774
904
 
775
905
  vc.saveBtnClicked = {(selectedRange: CMTimeRange) in
906
+ guard let asset = vc.asset else { return }
776
907
  if !self.enableSaveDialog {
777
- self.trim(viewController: vc,inputFile: destPath, videoDuration: self.vc!.asset!.duration.seconds, startTime: selectedRange.start.seconds, endTime: selectedRange.end.seconds, isVideoType: isVideoType)
908
+ self.trim(viewController: vc,inputFile: destPath, videoDuration: asset.duration.seconds, startTime: selectedRange.start.seconds, endTime: selectedRange.end.seconds, isVideoType: isVideoType)
778
909
  return
779
910
  }
780
911
 
@@ -784,7 +915,7 @@ extension VideoTrim {
784
915
 
785
916
  // Create OK button with action handler
786
917
  let ok = UIAlertAction(title: self.saveDialogConfirmText, style: .default, handler: { (action) -> Void in
787
- self.trim(viewController: vc,inputFile: destPath, videoDuration: vc.asset!.duration.seconds, startTime: selectedRange.start.seconds, endTime: selectedRange.end.seconds, isVideoType: isVideoType)
918
+ self.trim(viewController: vc,inputFile: destPath, videoDuration: asset.duration.seconds, startTime: selectedRange.start.seconds, endTime: selectedRange.end.seconds, isVideoType: isVideoType)
788
919
  })
789
920
 
790
921
  // Create Cancel button with action handlder
@@ -829,6 +960,7 @@ extension VideoTrim {
829
960
  vc.dismiss(animated: true, completion: {
830
961
  self.emitEventToJS("onHide", eventData: nil)
831
962
  self.isShowing = false
963
+ self.vc = nil
832
964
  })
833
965
  }
834
966
 
@@ -885,7 +1017,8 @@ extension VideoTrim {
885
1017
  // New Arch
886
1018
  @objc(deleteFile:)
887
1019
  public static func deleteFile(uri: String) -> Bool {
888
- let state = deleteFile(url: URL(string: uri)!)
1020
+ guard let url = URL(string: uri) else { return false }
1021
+ let state = deleteFile(url: url)
889
1022
  return state == 0
890
1023
  }
891
1024
 
@@ -918,7 +1051,10 @@ extension VideoTrim {
918
1051
  // New Arch
919
1052
  @objc(isValidFile:url:)
920
1053
  public static func isValidFile(url: String, completion: @escaping ([String: Any]) -> Void) -> Void {
921
- let fileURL = URL(string: url)!
1054
+ guard let fileURL = URL(string: url) ?? URL(fileURLWithPath: url) as URL? else {
1055
+ completion(["isValid": false, "fileType": "unknown", "duration": -1])
1056
+ return
1057
+ }
922
1058
  checkFileValidity(url: fileURL) { isValid, fileType, duration in
923
1059
  if isValid {
924
1060
  print("Valid \(fileType) file with duration: \(duration) milliseconds")
@@ -1018,8 +1154,9 @@ extension VideoTrim {
1018
1154
 
1019
1155
  vc?.asset = loader.asset
1020
1156
 
1157
+ let duration = loader.asset?.duration.seconds ?? 0
1021
1158
  let eventPayload: [String: Any] = [
1022
- "duration": loader.asset!.duration.seconds * 1000,
1159
+ "duration": duration * 1000,
1023
1160
  ]
1024
1161
  self.emitEventToJS("onLoad", eventData: eventPayload)
1025
1162
  }
@@ -1029,15 +1166,15 @@ extension VideoTrim {
1029
1166
  // MARK: DocumentPicker delegate
1030
1167
  extension VideoTrim {
1031
1168
  public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
1032
- if removeAfterSavedToDocuments {
1033
- let _ = VideoTrim.deleteFile(url: outputFile!)
1169
+ if removeAfterSavedToDocuments, let outputFile = self.outputFile {
1170
+ let _ = VideoTrim.deleteFile(url: outputFile)
1034
1171
  }
1035
1172
  closeEditor()
1036
1173
  }
1037
1174
 
1038
1175
  public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
1039
- if removeAfterFailedToSaveDocuments {
1040
- let _ = VideoTrim.deleteFile(url: outputFile!)
1176
+ if removeAfterFailedToSaveDocuments, let outputFile = self.outputFile {
1177
+ let _ = VideoTrim.deleteFile(url: outputFile)
1041
1178
  }
1042
1179
  closeEditor()
1043
1180
  }
@@ -446,17 +446,15 @@ import AVFoundation
446
446
  self.thumbnails.removeAll(where: {uuidsToRemove.contains($0.uuid)})
447
447
  })
448
448
 
449
- var seenIndex = 0
450
449
  generator.requestedTimeToleranceBefore = .zero
451
450
  generator.requestedTimeToleranceAfter = .zero
452
451
  generator.generateCGImagesAsynchronously(forTimes: times) { requestedTime, cgImage, actualTime, result, error in
453
452
  DispatchQueue.main.async {
454
- seenIndex += 1
455
-
456
453
  guard let cgImage = cgImage else {return}
454
+ guard let index = newThumbnails.firstIndex(where: { CMTimeCompare($0.time, requestedTime) == 0 }) else {return}
457
455
  let image = UIImage(cgImage: cgImage)
458
456
 
459
- let imageView = newThumbnails[seenIndex - 1].imageView
457
+ let imageView = newThumbnails[index].imageView
460
458
  UIView.transition(with: imageView, duration: 0.25, options: [.transitionCrossDissolve], animations: {
461
459
  imageView.image = image
462
460
  })