react-native-video-trim 7.1.1 → 8.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/README.md +257 -1
- package/android/src/main/java/com/videotrim/BaseVideoTrimModule.kt +488 -34
- package/android/src/main/java/com/videotrim/utils/StorageUtil.kt +95 -36
- package/android/src/main/java/com/videotrim/utils/VideoTrimmerUtil.kt +38 -16
- package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.kt +92 -5
- package/android/src/main/res/drawable/speaker_slash_fill.xml +19 -0
- package/android/src/main/res/drawable/speaker_wave_2_fill.xml +23 -0
- package/android/src/main/res/layout/video_trimmer_view.xml +25 -3
- package/android/src/newarch/VideoTrimModule.kt +33 -0
- package/android/src/oldarch/VideoTrimModule.kt +41 -0
- package/android/src/oldarch/VideoTrimSpec.kt +17 -0
- package/ios/VideoTrim.mm +160 -1
- package/ios/VideoTrim.swift +632 -39
- package/ios/VideoTrimmerViewController.swift +129 -28
- package/lib/module/NativeVideoTrim.js +52 -0
- package/lib/module/NativeVideoTrim.js.map +1 -1
- package/lib/module/index.js +143 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/NativeVideoTrim.d.ts +161 -0
- package/lib/typescript/src/NativeVideoTrim.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +62 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/NativeVideoTrim.ts +183 -0
- package/src/index.tsx +186 -0
package/ios/VideoTrim.swift
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React
|
|
2
2
|
import Photos
|
|
3
3
|
import AVFoundation
|
|
4
|
+
import UIKit
|
|
4
5
|
import ffmpegkit
|
|
5
6
|
|
|
6
7
|
let FILE_PREFIX = "trimmedVideo"
|
|
@@ -62,6 +63,10 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
|
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
private var removeAudio: Bool {
|
|
67
|
+
return editorConfig?["removeAudio"] as? Bool ?? false
|
|
68
|
+
}
|
|
69
|
+
|
|
65
70
|
// MARK: trimming with editor options
|
|
66
71
|
private var trimmingText: String {
|
|
67
72
|
get {
|
|
@@ -262,6 +267,22 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
|
|
|
262
267
|
}
|
|
263
268
|
}
|
|
264
269
|
|
|
270
|
+
/// Chains FFmpeg `atempo` filters so each stage stays within 0.5–2.0.
|
|
271
|
+
private func buildAtempoChain(_ speed: Double) -> String {
|
|
272
|
+
var remaining = speed
|
|
273
|
+
var filters: [String] = []
|
|
274
|
+
while remaining < 0.5 {
|
|
275
|
+
filters.append("atempo=0.5")
|
|
276
|
+
remaining /= 0.5
|
|
277
|
+
}
|
|
278
|
+
while remaining > 2.0 {
|
|
279
|
+
filters.append("atempo=2.0")
|
|
280
|
+
remaining /= 2.0
|
|
281
|
+
}
|
|
282
|
+
filters.append("atempo=\(remaining)")
|
|
283
|
+
return filters.joined(separator: ",")
|
|
284
|
+
}
|
|
285
|
+
|
|
265
286
|
private func trim(viewController: VideoTrimmerViewController, inputFile: URL, videoDuration: Double, startTime: Double, endTime: Double, isVideoType: Bool) {
|
|
266
287
|
guard !isTrimming else { return }
|
|
267
288
|
isTrimming = true
|
|
@@ -348,10 +369,13 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
|
|
|
348
369
|
var videoFilters: [String] = []
|
|
349
370
|
let hasUserTransform = vc != nil && (vc!.rotationCount != 0 || vc!.isFlipped)
|
|
350
371
|
let cropNorm = vc?.cropNormalizedRect
|
|
372
|
+
let stripAudio = removeAudio || viewController.isMuted
|
|
373
|
+
let playbackSpeed = viewController.speed
|
|
374
|
+
let needsSpeed = abs(playbackSpeed - 1.0) > 0.0001
|
|
351
375
|
// Re-encode is required when: (1) user applied flip/rotate, (2) user cropped, or
|
|
352
|
-
// (3) enablePreciseTrimming is on. In
|
|
353
|
-
//
|
|
354
|
-
let needsReEncode = hasUserTransform || cropNorm != nil || enablePreciseTrimming
|
|
376
|
+
// (3) enablePreciseTrimming is on, or (4) export speed != 1.0. In those cases, -c copy
|
|
377
|
+
// won't work because we need filters or frame-accurate cut points.
|
|
378
|
+
let needsReEncode = hasUserTransform || cropNorm != nil || enablePreciseTrimming || needsSpeed
|
|
355
379
|
|
|
356
380
|
if needsReEncode, let vc = vc {
|
|
357
381
|
// -noautorotate disables FFmpeg's automatic rotation, so we must manually
|
|
@@ -415,6 +439,10 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
|
|
|
415
439
|
}
|
|
416
440
|
}
|
|
417
441
|
|
|
442
|
+
if needsReEncode && needsSpeed {
|
|
443
|
+
videoFilters.append("setpts=\(1.0 / playbackSpeed)*PTS")
|
|
444
|
+
}
|
|
445
|
+
|
|
418
446
|
guard let outputFile = outputFile else {
|
|
419
447
|
self.onError(message: "Output file path is nil", code: .trimmingFailed)
|
|
420
448
|
return
|
|
@@ -447,19 +475,28 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
|
|
|
447
475
|
"h264_videotoolbox",
|
|
448
476
|
"-b:v",
|
|
449
477
|
bitrateStr,
|
|
450
|
-
|
|
451
|
-
|
|
478
|
+
])
|
|
479
|
+
if stripAudio {
|
|
480
|
+
cmds.append("-an")
|
|
481
|
+
} else if needsSpeed {
|
|
482
|
+
cmds.append(contentsOf: ["-af", buildAtempoChain(playbackSpeed), "-c:a", "aac"])
|
|
483
|
+
} else {
|
|
484
|
+
cmds.append(contentsOf: ["-c:a", "copy"])
|
|
485
|
+
}
|
|
486
|
+
cmds.append(contentsOf: [
|
|
452
487
|
"-metadata",
|
|
453
488
|
"creation_time=\(dateTime)",
|
|
454
489
|
outputFile.path
|
|
455
490
|
])
|
|
456
491
|
} else {
|
|
457
492
|
// Stream copy: no re-encoding, extremely fast but only cuts at keyframes.
|
|
493
|
+
cmds.append(contentsOf: ["-i", inputFile.path])
|
|
494
|
+
if stripAudio {
|
|
495
|
+
cmds.append(contentsOf: ["-c:v", "copy", "-an"])
|
|
496
|
+
} else {
|
|
497
|
+
cmds.append(contentsOf: ["-c", "copy"])
|
|
498
|
+
}
|
|
458
499
|
cmds.append(contentsOf: [
|
|
459
|
-
"-i",
|
|
460
|
-
inputFile.path,
|
|
461
|
-
"-c",
|
|
462
|
-
"copy",
|
|
463
500
|
"-metadata",
|
|
464
501
|
"creation_time=\(dateTime)",
|
|
465
502
|
outputFile.path
|
|
@@ -586,16 +623,14 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
|
|
|
586
623
|
}
|
|
587
624
|
|
|
588
625
|
// New Arch
|
|
589
|
-
@objc(
|
|
626
|
+
@objc(trimWithInputFile:config:completion:)
|
|
590
627
|
public func _trim(inputFile: String, config: NSDictionary, completion: @escaping ([String: Any]) -> Void) {
|
|
591
|
-
|
|
592
|
-
|
|
628
|
+
let destPath = URL(string: inputFile) ?? URL(fileURLWithPath: inputFile)
|
|
629
|
+
if destPath.path.isEmpty {
|
|
630
|
+
completion([
|
|
593
631
|
"success": false,
|
|
594
632
|
"message": "Invalid input file path",
|
|
595
|
-
] as [String
|
|
596
|
-
|
|
597
|
-
completion(result)
|
|
598
|
-
|
|
633
|
+
] as [String: Any])
|
|
599
634
|
return
|
|
600
635
|
}
|
|
601
636
|
|
|
@@ -620,10 +655,13 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
|
|
|
620
655
|
]
|
|
621
656
|
|
|
622
657
|
// Headless trim: no editor UI, so no transforms (flip/rotate/crop) are possible.
|
|
623
|
-
// The only reason to re-encode here is enablePreciseTrimming for frame-accurate cuts.
|
|
624
658
|
let enablePrecise = config["enablePreciseTrimming"] as? Bool ?? false
|
|
659
|
+
let stripAudio = config["removeAudio"] as? Bool ?? false
|
|
660
|
+
let speed = config["speed"] as? Double ?? 1.0
|
|
661
|
+
let needsSpeed = abs(speed - 1.0) > 0.0001
|
|
662
|
+
let needsReEncode = enablePrecise || needsSpeed
|
|
625
663
|
|
|
626
|
-
if
|
|
664
|
+
if needsReEncode {
|
|
627
665
|
// Match source bitrate to preserve quality; fall back to 10 Mbps.
|
|
628
666
|
var bitrateStr = "10M"
|
|
629
667
|
let asset = AVURLAsset(url: destPath)
|
|
@@ -634,28 +672,44 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
|
|
|
634
672
|
}
|
|
635
673
|
}
|
|
636
674
|
|
|
675
|
+
var videoFilters: [String] = []
|
|
676
|
+
if needsSpeed {
|
|
677
|
+
videoFilters.append("setpts=\(1.0 / speed)*PTS")
|
|
678
|
+
}
|
|
679
|
+
|
|
637
680
|
// No -noautorotate here: headless trim has no manual rotation filters,
|
|
638
681
|
// so FFmpeg's auto-rotation produces the correct output orientation.
|
|
682
|
+
cmds.append(contentsOf: ["-i", destPath.path])
|
|
683
|
+
if !videoFilters.isEmpty {
|
|
684
|
+
cmds.append(contentsOf: ["-vf", videoFilters.joined(separator: ",")])
|
|
685
|
+
}
|
|
639
686
|
cmds.append(contentsOf: [
|
|
640
|
-
"-i",
|
|
641
|
-
destPath.path,
|
|
642
687
|
"-c:v",
|
|
643
688
|
"h264_videotoolbox",
|
|
644
689
|
"-b:v",
|
|
645
690
|
bitrateStr,
|
|
646
|
-
|
|
647
|
-
|
|
691
|
+
])
|
|
692
|
+
if stripAudio {
|
|
693
|
+
cmds.append("-an")
|
|
694
|
+
} else if needsSpeed {
|
|
695
|
+
cmds.append(contentsOf: ["-af", buildAtempoChain(speed), "-c:a", "aac"])
|
|
696
|
+
} else {
|
|
697
|
+
cmds.append(contentsOf: ["-c:a", "copy"])
|
|
698
|
+
}
|
|
699
|
+
cmds.append(contentsOf: [
|
|
648
700
|
"-metadata",
|
|
649
701
|
"creation_time=\(dateTime)",
|
|
650
702
|
outputFile.path
|
|
651
703
|
])
|
|
652
704
|
} else {
|
|
653
705
|
// Stream copy: no re-encoding, extremely fast but only cuts at keyframes.
|
|
706
|
+
cmds.append(contentsOf: ["-i", destPath.path])
|
|
707
|
+
if stripAudio {
|
|
708
|
+
cmds.append(contentsOf: ["-c:v", "copy", "-an"])
|
|
709
|
+
} else {
|
|
710
|
+
cmds.append(contentsOf: ["-c", "copy"])
|
|
711
|
+
}
|
|
654
712
|
cmds.append(contentsOf: [
|
|
655
|
-
"-i",
|
|
656
|
-
destPath.path,
|
|
657
|
-
"-c",
|
|
658
|
-
"copy",
|
|
659
713
|
"-metadata",
|
|
660
714
|
"creation_time=\(dateTime)",
|
|
661
715
|
outputFile.path
|
|
@@ -765,9 +819,10 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
|
|
|
765
819
|
completion(result)
|
|
766
820
|
} else {
|
|
767
821
|
// FAILURE
|
|
822
|
+
let logs = session?.getAllLogsAsString() ?? ""
|
|
768
823
|
let result = [
|
|
769
824
|
"success": false,
|
|
770
|
-
"message": "Command failed with rc \(String(describing: returnCode)).\(String(describing: session?.getFailStackTrace()))",
|
|
825
|
+
"message": "Command failed with rc \(String(describing: returnCode)).\(String(describing: session?.getFailStackTrace()))\n\(logs)",
|
|
771
826
|
] as [String : Any]
|
|
772
827
|
|
|
773
828
|
completion(result)
|
|
@@ -1031,23 +1086,30 @@ extension VideoTrim {
|
|
|
1031
1086
|
resolve(VideoTrim.deleteFile(uri: uri))
|
|
1032
1087
|
}
|
|
1033
1088
|
|
|
1089
|
+
// Scans both the documents directory (showEditor/trim outputs) and the caches
|
|
1090
|
+
// directory (headless API outputs) for files matching our FILE_PREFIX.
|
|
1034
1091
|
private static func listFiles() -> [URL] {
|
|
1035
1092
|
var files: [URL] = []
|
|
1036
|
-
|
|
1037
|
-
let
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1093
|
+
|
|
1094
|
+
let dirs = [
|
|
1095
|
+
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!,
|
|
1096
|
+
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!,
|
|
1097
|
+
]
|
|
1098
|
+
|
|
1099
|
+
for dir in dirs {
|
|
1100
|
+
do {
|
|
1101
|
+
let directoryContents = try FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)
|
|
1102
|
+
|
|
1103
|
+
for fileURL in directoryContents {
|
|
1104
|
+
if fileURL.lastPathComponent.starts(with: FILE_PREFIX) {
|
|
1105
|
+
files.append(fileURL)
|
|
1106
|
+
}
|
|
1045
1107
|
}
|
|
1108
|
+
} catch {
|
|
1109
|
+
print("[listFiles] Error when retrieving files in \(dir): \(error)")
|
|
1046
1110
|
}
|
|
1047
|
-
} catch {
|
|
1048
|
-
print("[listFiles] Error when retrieving files: \(error)")
|
|
1049
1111
|
}
|
|
1050
|
-
|
|
1112
|
+
|
|
1051
1113
|
return files
|
|
1052
1114
|
}
|
|
1053
1115
|
|
|
@@ -1083,6 +1145,519 @@ extension VideoTrim {
|
|
|
1083
1145
|
})
|
|
1084
1146
|
}
|
|
1085
1147
|
|
|
1148
|
+
// MARK: - Headless API: getFrameAt
|
|
1149
|
+
// Extracts a single video frame as JPEG/PNG using AVAssetImageGenerator.
|
|
1150
|
+
// Output goes to the caches directory (OS-managed, auto-purged under storage pressure).
|
|
1151
|
+
@objc
|
|
1152
|
+
public static func getFrameAt(_ url: String, options: NSDictionary, completion: @escaping ([String: Any]) -> Void) {
|
|
1153
|
+
let destPath = URL(string: url) ?? URL(fileURLWithPath: url)
|
|
1154
|
+
|
|
1155
|
+
let time = options["time"] as? Double ?? 0
|
|
1156
|
+
let format = options["format"] as? String ?? "jpeg"
|
|
1157
|
+
let qualityNum = options["quality"] as? NSNumber
|
|
1158
|
+
let quality = qualityNum?.intValue ?? 80
|
|
1159
|
+
let maxWidth = options["maxWidth"] as? Int ?? -1
|
|
1160
|
+
let maxHeight = options["maxHeight"] as? Int ?? -1
|
|
1161
|
+
|
|
1162
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
1163
|
+
let asset = AVURLAsset(url: destPath)
|
|
1164
|
+
let generator = AVAssetImageGenerator(asset: asset)
|
|
1165
|
+
generator.appliesPreferredTrackTransform = true
|
|
1166
|
+
generator.requestedTimeToleranceBefore = CMTime(seconds: 0.1, preferredTimescale: 600)
|
|
1167
|
+
generator.requestedTimeToleranceAfter = CMTime(seconds: 0.1, preferredTimescale: 600)
|
|
1168
|
+
|
|
1169
|
+
if maxWidth > 0 || maxHeight > 0 {
|
|
1170
|
+
let w = maxWidth > 0 ? maxWidth : 0
|
|
1171
|
+
let h = maxHeight > 0 ? maxHeight : 0
|
|
1172
|
+
generator.maximumSize = CGSize(width: CGFloat(w), height: CGFloat(h))
|
|
1173
|
+
} else if let track = asset.tracks(withMediaType: .video).first {
|
|
1174
|
+
// AVAssetImageGenerator defaults to a reduced size if maximumSize is not set.
|
|
1175
|
+
// Explicitly set it to the video's natural size (accounting for rotation via
|
|
1176
|
+
// preferredTransform) to ensure full-resolution frame extraction.
|
|
1177
|
+
let size = track.naturalSize.applying(track.preferredTransform)
|
|
1178
|
+
generator.maximumSize = CGSize(width: abs(size.width), height: abs(size.height))
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
let cmTime = CMTime(value: CMTimeValue(time), timescale: 1000)
|
|
1182
|
+
|
|
1183
|
+
do {
|
|
1184
|
+
let cgImage = try generator.copyCGImage(at: cmTime, actualTime: nil)
|
|
1185
|
+
let uiImage = UIImage(cgImage: cgImage)
|
|
1186
|
+
|
|
1187
|
+
let timestamp = Int(Date().timeIntervalSince1970)
|
|
1188
|
+
let ext = format == "png" ? "png" : "jpg"
|
|
1189
|
+
let outputName = "\(FILE_PREFIX)_frame_\(timestamp).\(ext)"
|
|
1190
|
+
let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
|
1191
|
+
let outputURL = cacheDirectory.appendingPathComponent(outputName)
|
|
1192
|
+
|
|
1193
|
+
let data: Data?
|
|
1194
|
+
if format == "png" {
|
|
1195
|
+
data = uiImage.pngData()
|
|
1196
|
+
} else {
|
|
1197
|
+
data = uiImage.jpegData(compressionQuality: CGFloat(quality) / 100.0)
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
guard let imageData = data else {
|
|
1201
|
+
completion(["error": "Failed to encode image"])
|
|
1202
|
+
return
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
try imageData.write(to: outputURL)
|
|
1206
|
+
completion(["outputPath": outputURL.absoluteString])
|
|
1207
|
+
} catch {
|
|
1208
|
+
completion(["error": "Failed to extract frame: \(error.localizedDescription)"])
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Old Arch
|
|
1214
|
+
@objc(getFrameAt:withOptions:withResolver:withRejecter:)
|
|
1215
|
+
func getFrameAt(_ url: String, withOptions options: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
1216
|
+
VideoTrim.getFrameAt(url, options: options, completion: { payload in
|
|
1217
|
+
if let error = payload["error"] as? String {
|
|
1218
|
+
reject("ERR_FRAME_EXTRACTION", error, NSError(domain: "", code: 200, userInfo: nil))
|
|
1219
|
+
} else {
|
|
1220
|
+
resolve(payload)
|
|
1221
|
+
}
|
|
1222
|
+
})
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// MARK: - Headless API: extractAudio
|
|
1226
|
+
// Strips the video track, keeping only audio. Default output is m4a (AAC) because the
|
|
1227
|
+
// default FFmpegKit builds do not include libmp3lame, so mp3 encoding would fail.
|
|
1228
|
+
@objc
|
|
1229
|
+
public static func extractAudio(_ url: String, options: NSDictionary, completion: @escaping ([String: Any]) -> Void) {
|
|
1230
|
+
let destPath = URL(string: url) ?? URL(fileURLWithPath: url)
|
|
1231
|
+
|
|
1232
|
+
let outputExt = options["outputExt"] as? String ?? "m4a"
|
|
1233
|
+
let timestamp = Int(Date().timeIntervalSince1970)
|
|
1234
|
+
let outputName = "\(FILE_PREFIX)_audio_\(timestamp).\(outputExt)"
|
|
1235
|
+
let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
|
1236
|
+
let outputFile = cacheDirectory.appendingPathComponent(outputName)
|
|
1237
|
+
|
|
1238
|
+
let cmds = ["-i", destPath.path, "-vn", "-y", outputFile.path]
|
|
1239
|
+
print("extractAudio command:", cmds.joined(separator: " "))
|
|
1240
|
+
|
|
1241
|
+
FFmpegKit.execute(withArgumentsAsync: cmds, withCompleteCallback: { session in
|
|
1242
|
+
let returnCode = session?.getReturnCode()
|
|
1243
|
+
if ReturnCode.isSuccess(returnCode) {
|
|
1244
|
+
let asset = AVURLAsset(url: outputFile)
|
|
1245
|
+
let duration = CMTimeGetSeconds(asset.duration) * 1000
|
|
1246
|
+
completion([
|
|
1247
|
+
"outputPath": outputFile.absoluteString,
|
|
1248
|
+
"duration": duration.rounded()
|
|
1249
|
+
])
|
|
1250
|
+
} else {
|
|
1251
|
+
let logs = session?.getAllLogsAsString() ?? ""
|
|
1252
|
+
completion(["error": "Failed to extract audio: rc \(String(describing: returnCode))\n\(logs)"])
|
|
1253
|
+
}
|
|
1254
|
+
}, withLogCallback: nil, withStatisticsCallback: nil)
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// Old Arch
|
|
1258
|
+
@objc(extractAudio:withOptions:withResolver:withRejecter:)
|
|
1259
|
+
func extractAudio(_ url: String, withOptions options: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
1260
|
+
VideoTrim.extractAudio(url, options: options, completion: { payload in
|
|
1261
|
+
if let error = payload["error"] as? String {
|
|
1262
|
+
reject("ERR_EXTRACT_AUDIO", error, NSError(domain: "", code: 200, userInfo: nil))
|
|
1263
|
+
} else {
|
|
1264
|
+
resolve(payload)
|
|
1265
|
+
}
|
|
1266
|
+
})
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// MARK: - Headless API: compress
|
|
1270
|
+
// Re-encodes video with h264_videotoolbox (hardware) at the requested quality/bitrate.
|
|
1271
|
+
// Uses CRF-style -global_quality for quality presets, or explicit -b:v for custom bitrate.
|
|
1272
|
+
@objc
|
|
1273
|
+
public static func compress(_ url: String, options: NSDictionary, completion: @escaping ([String: Any]) -> Void) {
|
|
1274
|
+
let destPath = URL(string: url) ?? URL(fileURLWithPath: url)
|
|
1275
|
+
|
|
1276
|
+
let quality = options["quality"] as? String ?? "medium"
|
|
1277
|
+
let bitrate = options["bitrate"] as? Double ?? -1
|
|
1278
|
+
let width = options["width"] as? Int ?? -1
|
|
1279
|
+
let height = options["height"] as? Int ?? -1
|
|
1280
|
+
let frameRate = options["frameRate"] as? Double ?? -1
|
|
1281
|
+
let outputExt = options["outputExt"] as? String ?? "mp4"
|
|
1282
|
+
let removeAudio = options["removeAudio"] as? Bool ?? false
|
|
1283
|
+
|
|
1284
|
+
let timestamp = Int(Date().timeIntervalSince1970)
|
|
1285
|
+
let outputName = "\(FILE_PREFIX)_compressed_\(timestamp).\(outputExt)"
|
|
1286
|
+
let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
|
1287
|
+
let outputFile = cacheDirectory.appendingPathComponent(outputName)
|
|
1288
|
+
|
|
1289
|
+
var cmds: [String] = ["-i", destPath.path]
|
|
1290
|
+
var videoFilters: [String] = []
|
|
1291
|
+
|
|
1292
|
+
if width > 0 && height > 0 {
|
|
1293
|
+
videoFilters.append("scale=\(width):\(height)")
|
|
1294
|
+
} else if width > 0 {
|
|
1295
|
+
videoFilters.append("scale=\(width):-2")
|
|
1296
|
+
} else if height > 0 {
|
|
1297
|
+
videoFilters.append("scale=-2:\(height)")
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
if !videoFilters.isEmpty {
|
|
1301
|
+
cmds.append(contentsOf: ["-vf", videoFilters.joined(separator: ",")])
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
cmds.append(contentsOf: ["-c:v", "h264_videotoolbox"])
|
|
1305
|
+
|
|
1306
|
+
if bitrate > 0 {
|
|
1307
|
+
cmds.append(contentsOf: ["-b:v", "\(Int(bitrate))"])
|
|
1308
|
+
} else {
|
|
1309
|
+
let crf: String
|
|
1310
|
+
switch quality {
|
|
1311
|
+
case "low": crf = "28"
|
|
1312
|
+
case "high": crf = "18"
|
|
1313
|
+
default: crf = "23"
|
|
1314
|
+
}
|
|
1315
|
+
cmds.append(contentsOf: ["-global_quality", crf])
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
if frameRate > 0 {
|
|
1319
|
+
cmds.append(contentsOf: ["-r", "\(frameRate)"])
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
if removeAudio {
|
|
1323
|
+
cmds.append("-an")
|
|
1324
|
+
} else {
|
|
1325
|
+
cmds.append(contentsOf: ["-c:a", "aac"])
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
cmds.append(contentsOf: ["-y", outputFile.path])
|
|
1329
|
+
print("compress command:", cmds.joined(separator: " "))
|
|
1330
|
+
|
|
1331
|
+
FFmpegKit.execute(withArgumentsAsync: cmds, withCompleteCallback: { session in
|
|
1332
|
+
let returnCode = session?.getReturnCode()
|
|
1333
|
+
if ReturnCode.isSuccess(returnCode) {
|
|
1334
|
+
completion(["outputPath": outputFile.absoluteString])
|
|
1335
|
+
} else {
|
|
1336
|
+
let logs = session?.getAllLogsAsString() ?? ""
|
|
1337
|
+
completion(["error": "Compression failed: rc \(String(describing: returnCode))\n\(logs)"])
|
|
1338
|
+
}
|
|
1339
|
+
}, withLogCallback: nil, withStatisticsCallback: nil)
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// Old Arch
|
|
1343
|
+
@objc(compress:withOptions:withResolver:withRejecter:)
|
|
1344
|
+
func compress(_ url: String, withOptions options: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
1345
|
+
VideoTrim.compress(url, options: options, completion: { payload in
|
|
1346
|
+
if let error = payload["error"] as? String {
|
|
1347
|
+
reject("ERR_COMPRESS", error, NSError(domain: "", code: 200, userInfo: nil))
|
|
1348
|
+
} else {
|
|
1349
|
+
resolve(payload)
|
|
1350
|
+
}
|
|
1351
|
+
})
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// MARK: - Headless API: toGif
|
|
1355
|
+
// Two-pass GIF conversion: pass 1 generates an optimal palette, pass 2 encodes the GIF
|
|
1356
|
+
// using that palette for better color accuracy than single-pass dithering.
|
|
1357
|
+
@objc
|
|
1358
|
+
public static func toGif(_ url: String, options: NSDictionary, completion: @escaping ([String: Any]) -> Void) {
|
|
1359
|
+
let destPath = URL(string: url) ?? URL(fileURLWithPath: url)
|
|
1360
|
+
|
|
1361
|
+
let startTime = options["startTime"] as? Double ?? 0
|
|
1362
|
+
let endTime = options["endTime"] as? Double ?? -1
|
|
1363
|
+
let fps = options["fps"] as? Int ?? 10
|
|
1364
|
+
let width = options["width"] as? Int ?? -1
|
|
1365
|
+
|
|
1366
|
+
let timestamp = Int(Date().timeIntervalSince1970)
|
|
1367
|
+
let paletteName = "\(FILE_PREFIX)_palette_\(timestamp).png"
|
|
1368
|
+
let outputName = "\(FILE_PREFIX)_gif_\(timestamp).gif"
|
|
1369
|
+
let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
|
1370
|
+
let paletteFile = cacheDirectory.appendingPathComponent(paletteName)
|
|
1371
|
+
let outputFile = cacheDirectory.appendingPathComponent(outputName)
|
|
1372
|
+
|
|
1373
|
+
let scaleExpr = width > 0 ? "\(width):-1" : "-1:-1"
|
|
1374
|
+
let filterBase = "fps=\(fps),scale=\(scaleExpr):flags=lanczos"
|
|
1375
|
+
|
|
1376
|
+
var timeArgs: [String] = []
|
|
1377
|
+
if startTime > 0 { timeArgs.append(contentsOf: ["-ss", "\(startTime)ms"]) }
|
|
1378
|
+
if endTime > 0 { timeArgs.append(contentsOf: ["-to", "\(endTime)ms"]) }
|
|
1379
|
+
|
|
1380
|
+
let pass1 = timeArgs + ["-i", destPath.path, "-vf", "\(filterBase),palettegen", "-y", paletteFile.path]
|
|
1381
|
+
print("toGif pass1 command:", pass1.joined(separator: " "))
|
|
1382
|
+
|
|
1383
|
+
FFmpegKit.execute(withArgumentsAsync: pass1, withCompleteCallback: { session in
|
|
1384
|
+
guard ReturnCode.isSuccess(session?.getReturnCode()) else {
|
|
1385
|
+
let logs = session?.getAllLogsAsString() ?? ""
|
|
1386
|
+
completion(["error": "GIF palette generation failed\n\(logs)"])
|
|
1387
|
+
return
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
let pass2 = timeArgs + [
|
|
1391
|
+
"-i", destPath.path,
|
|
1392
|
+
"-i", paletteFile.path,
|
|
1393
|
+
"-lavfi", "[0:v]\(filterBase)[x];[x][1:v]paletteuse",
|
|
1394
|
+
"-y", outputFile.path
|
|
1395
|
+
]
|
|
1396
|
+
print("toGif pass2 command:", pass2.joined(separator: " "))
|
|
1397
|
+
|
|
1398
|
+
FFmpegKit.execute(withArgumentsAsync: pass2, withCompleteCallback: { session2 in
|
|
1399
|
+
try? FileManager.default.removeItem(at: paletteFile)
|
|
1400
|
+
|
|
1401
|
+
guard ReturnCode.isSuccess(session2?.getReturnCode()) else {
|
|
1402
|
+
let logs = session2?.getAllLogsAsString() ?? ""
|
|
1403
|
+
completion(["error": "GIF creation failed\n\(logs)"])
|
|
1404
|
+
return
|
|
1405
|
+
}
|
|
1406
|
+
completion(["outputPath": outputFile.absoluteString])
|
|
1407
|
+
}, withLogCallback: nil, withStatisticsCallback: nil)
|
|
1408
|
+
}, withLogCallback: nil, withStatisticsCallback: nil)
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Old Arch
|
|
1412
|
+
@objc(toGif:withOptions:withResolver:withRejecter:)
|
|
1413
|
+
func toGif(_ url: String, withOptions options: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
1414
|
+
VideoTrim.toGif(url, options: options, completion: { payload in
|
|
1415
|
+
if let error = payload["error"] as? String {
|
|
1416
|
+
reject("ERR_GIF", error, NSError(domain: "", code: 200, userInfo: nil))
|
|
1417
|
+
} else {
|
|
1418
|
+
resolve(payload)
|
|
1419
|
+
}
|
|
1420
|
+
})
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// MARK: - Headless API: merge
|
|
1424
|
+
// Concatenates multiple local video files using FFmpeg's concat *filter* (not demuxer).
|
|
1425
|
+
// Each input is normalized to the first clip's resolution via scale+pad+setsar+format
|
|
1426
|
+
// before entering the concat, so clips with different dimensions, pixel formats, or SARs
|
|
1427
|
+
// merge correctly (mismatched inputs get letterboxed/pillarboxed with black bars).
|
|
1428
|
+
//
|
|
1429
|
+
// Bitrate: probes all input videos and uses the highest detected bitrate as the target
|
|
1430
|
+
// (-b:v) so the output quality matches the best source. Falls back to 10 Mbps.
|
|
1431
|
+
//
|
|
1432
|
+
// Limitation: only supports local file paths. Remote URLs are not supported because the
|
|
1433
|
+
// default FFmpegKit build does not include OpenSSL (--disable-openssl).
|
|
1434
|
+
@objc
|
|
1435
|
+
public static func merge(_ urls: [String], options: NSDictionary, completion: @escaping ([String: Any]) -> Void) {
|
|
1436
|
+
let outputExt = options["outputExt"] as? String ?? "mp4"
|
|
1437
|
+
let timestamp = Int(Date().timeIntervalSince1970)
|
|
1438
|
+
let outputName = "\(FILE_PREFIX)_merged_\(timestamp).\(outputExt)"
|
|
1439
|
+
let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
|
1440
|
+
let outputFile = cacheDirectory.appendingPathComponent(outputName)
|
|
1441
|
+
|
|
1442
|
+
guard !urls.isEmpty else {
|
|
1443
|
+
completion(["error": "No input URLs"])
|
|
1444
|
+
return
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
var cmds: [String] = []
|
|
1448
|
+
var maxBitrate: Int = 0
|
|
1449
|
+
for urlStr in urls {
|
|
1450
|
+
let u = URL(string: urlStr) ?? URL(fileURLWithPath: urlStr)
|
|
1451
|
+
cmds.append(contentsOf: ["-i", u.path])
|
|
1452
|
+
let asset = AVURLAsset(url: u)
|
|
1453
|
+
if let track = asset.tracks(withMediaType: .video).first {
|
|
1454
|
+
maxBitrate = max(maxBitrate, Int(track.estimatedDataRate))
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
let bitrateStr = maxBitrate > 0 ? "\(maxBitrate)" : "10M"
|
|
1458
|
+
|
|
1459
|
+
// Use the first clip's dimensions and frame rate as the target for all inputs.
|
|
1460
|
+
let firstURL = URL(string: urls[0]) ?? URL(fileURLWithPath: urls[0])
|
|
1461
|
+
let firstAsset = AVURLAsset(url: firstURL)
|
|
1462
|
+
var targetW = 1280; var targetH = 720
|
|
1463
|
+
var targetFps = 30
|
|
1464
|
+
if let track = firstAsset.tracks(withMediaType: .video).first {
|
|
1465
|
+
let size = track.naturalSize.applying(track.preferredTransform)
|
|
1466
|
+
targetW = Int(abs(size.width))
|
|
1467
|
+
targetH = Int(abs(size.height))
|
|
1468
|
+
targetFps = min(Int(ceil(track.nominalFrameRate)), 30)
|
|
1469
|
+
if targetFps <= 0 { targetFps = 30 }
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// Normalize each input to the same resolution, pixel format, SAR, and frame rate
|
|
1473
|
+
// before concat. The fps filter prevents massive frame duplication when inputs have
|
|
1474
|
+
// very different frame rates (e.g. 24fps + 60fps would cause thousands of dupes).
|
|
1475
|
+
let n = urls.count
|
|
1476
|
+
let scaleFilter = "scale=\(targetW):\(targetH):force_original_aspect_ratio=decrease,pad=\(targetW):\(targetH):(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p,fps=\(targetFps)"
|
|
1477
|
+
var scaleParts: [String] = []
|
|
1478
|
+
for i in 0..<n {
|
|
1479
|
+
scaleParts.append("[\(i):v:0]\(scaleFilter)[v\(i)]")
|
|
1480
|
+
}
|
|
1481
|
+
let concatInputs = (0..<n).map { "[v\($0)][\($0):a:0]" }.joined()
|
|
1482
|
+
let filterComplex = scaleParts.joined(separator: ";") + ";" + concatInputs + "concat=n=\(n):v=1:a=1[outv][outa]"
|
|
1483
|
+
|
|
1484
|
+
cmds.append(contentsOf: [
|
|
1485
|
+
"-filter_complex", filterComplex,
|
|
1486
|
+
"-map", "[outv]", "-map", "[outa]",
|
|
1487
|
+
"-c:v", "h264_videotoolbox", "-b:v", bitrateStr,
|
|
1488
|
+
"-c:a", "aac",
|
|
1489
|
+
"-y", outputFile.path
|
|
1490
|
+
])
|
|
1491
|
+
print("merge command:", cmds.joined(separator: " "))
|
|
1492
|
+
|
|
1493
|
+
FFmpegKit.execute(withArgumentsAsync: cmds, withCompleteCallback: { session in
|
|
1494
|
+
let returnCode = session?.getReturnCode()
|
|
1495
|
+
if ReturnCode.isSuccess(returnCode) {
|
|
1496
|
+
let asset = AVURLAsset(url: outputFile)
|
|
1497
|
+
let duration = CMTimeGetSeconds(asset.duration) * 1000
|
|
1498
|
+
completion([
|
|
1499
|
+
"outputPath": outputFile.absoluteString,
|
|
1500
|
+
"duration": duration.rounded()
|
|
1501
|
+
])
|
|
1502
|
+
} else {
|
|
1503
|
+
let logs = session?.getAllLogsAsString() ?? ""
|
|
1504
|
+
completion(["error": "Merge failed: rc \(String(describing: returnCode))\n\(logs)"])
|
|
1505
|
+
}
|
|
1506
|
+
}, withLogCallback: nil, withStatisticsCallback: nil)
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// Old Arch
|
|
1510
|
+
@objc(merge:withOptions:withResolver:withRejecter:)
|
|
1511
|
+
func merge(_ urls: [String], withOptions options: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
1512
|
+
VideoTrim.merge(urls, options: options, completion: { payload in
|
|
1513
|
+
if let error = payload["error"] as? String {
|
|
1514
|
+
reject("ERR_MERGE", error, NSError(domain: "", code: 200, userInfo: nil))
|
|
1515
|
+
} else {
|
|
1516
|
+
resolve(payload)
|
|
1517
|
+
}
|
|
1518
|
+
})
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// MARK: - Utility: saveToPhoto
|
|
1522
|
+
// Saves a file to the Photo Library. Detects whether the file is an image or video by
|
|
1523
|
+
// extension, then calls the appropriate PHAssetChangeRequest factory method. Using the
|
|
1524
|
+
// wrong factory (e.g. creationRequestForAssetFromVideo for a .jpg) causes the Photos
|
|
1525
|
+
// app to treat the file as a broken video.
|
|
1526
|
+
|
|
1527
|
+
private static let imageExtensions: Set<String> = ["jpg", "jpeg", "png", "gif", "webp", "bmp", "heic", "heif", "tiff", "tif"]
|
|
1528
|
+
|
|
1529
|
+
@objc
|
|
1530
|
+
public static func saveToPhoto(_ filePath: String, completion: @escaping ([String: Any]) -> Void) {
|
|
1531
|
+
let fileURL = URL(string: filePath) ?? URL(fileURLWithPath: filePath)
|
|
1532
|
+
|
|
1533
|
+
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
|
1534
|
+
completion(["error": "File does not exist at path: \(filePath)"])
|
|
1535
|
+
return
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
let ext = fileURL.pathExtension.lowercased()
|
|
1539
|
+
let isImage = imageExtensions.contains(ext)
|
|
1540
|
+
|
|
1541
|
+
PHPhotoLibrary.requestAuthorization { status in
|
|
1542
|
+
guard status == .authorized else {
|
|
1543
|
+
completion(["error": "Permission to access Photo Library is not granted"])
|
|
1544
|
+
return
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
PHPhotoLibrary.shared().performChanges({
|
|
1548
|
+
if isImage {
|
|
1549
|
+
PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: fileURL)
|
|
1550
|
+
} else {
|
|
1551
|
+
PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: fileURL)
|
|
1552
|
+
}
|
|
1553
|
+
}) { success, error in
|
|
1554
|
+
if success {
|
|
1555
|
+
completion(["success": true])
|
|
1556
|
+
} else {
|
|
1557
|
+
completion(["error": "Failed to save to Photo Library: \(error?.localizedDescription ?? "Unknown error")"])
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// Old Arch
|
|
1564
|
+
@objc(saveToPhoto:withResolver:withRejecter:)
|
|
1565
|
+
func saveToPhoto(_ filePath: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
1566
|
+
VideoTrim.saveToPhoto(filePath, completion: { payload in
|
|
1567
|
+
if let error = payload["error"] as? String {
|
|
1568
|
+
reject("ERR_SAVE_TO_PHOTO", error, NSError(domain: "", code: 200, userInfo: nil))
|
|
1569
|
+
} else {
|
|
1570
|
+
resolve(payload)
|
|
1571
|
+
}
|
|
1572
|
+
})
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// MARK: - Utility: saveToDocuments
|
|
1576
|
+
// Presents UIDocumentPickerViewController in exportToService mode so the user can
|
|
1577
|
+
// choose where to save. Uses a standalone DocumentPickerDelegate (retained via
|
|
1578
|
+
// objc_setAssociatedObject) that is independent of the editor lifecycle.
|
|
1579
|
+
|
|
1580
|
+
@objc
|
|
1581
|
+
public static func saveToDocuments(_ filePath: String, completion: @escaping ([String: Any]) -> Void) {
|
|
1582
|
+
let fileURL = URL(string: filePath) ?? URL(fileURLWithPath: filePath)
|
|
1583
|
+
|
|
1584
|
+
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
|
1585
|
+
completion(["error": "File does not exist at path: \(filePath)"])
|
|
1586
|
+
return
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
DispatchQueue.main.async {
|
|
1590
|
+
let picker = UIDocumentPickerViewController(url: fileURL, in: .exportToService)
|
|
1591
|
+
picker.modalPresentationStyle = .formSheet
|
|
1592
|
+
|
|
1593
|
+
let delegate = DocumentPickerDelegate(completion: completion)
|
|
1594
|
+
picker.delegate = delegate
|
|
1595
|
+
objc_setAssociatedObject(picker, "delegate", delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|
1596
|
+
|
|
1597
|
+
if let root = RCTPresentedViewController() {
|
|
1598
|
+
root.present(picker, animated: true, completion: nil)
|
|
1599
|
+
} else {
|
|
1600
|
+
completion(["error": "No root view controller available"])
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// Old Arch
|
|
1606
|
+
@objc(saveToDocuments:withResolver:withRejecter:)
|
|
1607
|
+
func saveToDocuments(_ filePath: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
1608
|
+
VideoTrim.saveToDocuments(filePath, completion: { payload in
|
|
1609
|
+
if let error = payload["error"] as? String {
|
|
1610
|
+
reject("ERR_SAVE_TO_DOCUMENTS", error, NSError(domain: "", code: 200, userInfo: nil))
|
|
1611
|
+
} else {
|
|
1612
|
+
resolve(payload)
|
|
1613
|
+
}
|
|
1614
|
+
})
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// MARK: - Utility: share
|
|
1618
|
+
// Opens UIActivityViewController with the file URL. The completion handler resolves
|
|
1619
|
+
// with success=true if the user completed a share action, false if cancelled.
|
|
1620
|
+
|
|
1621
|
+
@objc
|
|
1622
|
+
public static func share(_ filePath: String, completion: @escaping ([String: Any]) -> Void) {
|
|
1623
|
+
let fileURL = URL(string: filePath) ?? URL(fileURLWithPath: filePath)
|
|
1624
|
+
|
|
1625
|
+
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
|
1626
|
+
completion(["error": "File does not exist at path: \(filePath)"])
|
|
1627
|
+
return
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
DispatchQueue.main.async {
|
|
1631
|
+
let activityVC = UIActivityViewController(activityItems: [fileURL], applicationActivities: nil)
|
|
1632
|
+
|
|
1633
|
+
activityVC.completionWithItemsHandler = { _, completed, _, error in
|
|
1634
|
+
if let error = error {
|
|
1635
|
+
completion(["error": "Sharing error: \(error.localizedDescription)"])
|
|
1636
|
+
return
|
|
1637
|
+
}
|
|
1638
|
+
completion(["success": completed])
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
if let root = RCTPresentedViewController() {
|
|
1642
|
+
root.present(activityVC, animated: true, completion: nil)
|
|
1643
|
+
} else {
|
|
1644
|
+
completion(["error": "No root view controller available"])
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// Old Arch
|
|
1650
|
+
@objc(share:withResolver:withRejecter:)
|
|
1651
|
+
func share(_ filePath: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
1652
|
+
VideoTrim.share(filePath, completion: { payload in
|
|
1653
|
+
if let error = payload["error"] as? String {
|
|
1654
|
+
reject("ERR_SHARE", error, NSError(domain: "", code: 200, userInfo: nil))
|
|
1655
|
+
} else {
|
|
1656
|
+
resolve(payload)
|
|
1657
|
+
}
|
|
1658
|
+
})
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1086
1661
|
private static func checkFileValidity(url: URL, completion: @escaping (Bool, String, Double) -> Void) {
|
|
1087
1662
|
let asset = AVAsset(url: url)
|
|
1088
1663
|
|
|
@@ -1182,3 +1757,21 @@ extension VideoTrim {
|
|
|
1182
1757
|
closeEditor()
|
|
1183
1758
|
}
|
|
1184
1759
|
}
|
|
1760
|
+
|
|
1761
|
+
/// Standalone delegate for the `saveToDocuments` utility (not tied to editor lifecycle).
|
|
1762
|
+
private class DocumentPickerDelegate: NSObject, UIDocumentPickerDelegate {
|
|
1763
|
+
private let completion: ([String: Any]) -> Void
|
|
1764
|
+
|
|
1765
|
+
init(completion: @escaping ([String: Any]) -> Void) {
|
|
1766
|
+
self.completion = completion
|
|
1767
|
+
super.init()
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
|
1771
|
+
completion(["success": true])
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
|
1775
|
+
completion(["success": false])
|
|
1776
|
+
}
|
|
1777
|
+
}
|