react-native-video-trim 7.1.1 → 8.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.
@@ -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 all three cases, -c copy won't work because
353
- // either we need video filters or we need frame-accurate cut points.
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
- "-c:a",
451
- "copy",
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(trim:url:config:)
626
+ @objc(trimWithInputFile:config:completion:)
590
627
  public func _trim(inputFile: String, config: NSDictionary, completion: @escaping ([String: Any]) -> Void) {
591
- guard let destPath = URL(string: inputFile) ?? URL(fileURLWithPath: inputFile) as URL? else {
592
- let result = [
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 : Any]
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 enablePrecise {
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
- "-c:a",
647
- "copy",
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 documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
1038
-
1039
- do {
1040
- let directoryContents = try FileManager.default.contentsOfDirectory(at: documentsDirectory, includingPropertiesForKeys: nil)
1041
-
1042
- for fileURL in directoryContents {
1043
- if fileURL.lastPathComponent.starts(with: FILE_PREFIX) {
1044
- files.append(fileURL)
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
+ }