react-native-attachment-kit 1.0.5 → 1.0.8

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 (38) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +100 -3
  3. package/android/build.gradle +14 -0
  4. package/android/src/main/libs/rnattachmentkit-release.aar +0 -0
  5. package/android/src/newarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +280 -13
  6. package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +282 -13
  7. package/ios/RNAttachmentKit.m +18 -0
  8. package/ios/RNAttachmentKit.swift +447 -9
  9. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Assets.car +0 -0
  10. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Headers/RNAttachmentKit-Swift.h +21 -5
  11. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Info.plist +0 -0
  12. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.abi.json +33440 -7566
  13. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +193 -5
  14. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  15. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftinterface +193 -5
  16. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  17. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  18. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeResources +15 -15
  19. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeSignature +0 -0
  20. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Assets.car +0 -0
  21. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Headers/RNAttachmentKit-Swift.h +42 -10
  22. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Info.plist +0 -0
  23. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +33440 -7566
  24. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +193 -5
  25. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  26. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftinterface +193 -5
  27. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +33440 -7566
  28. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +193 -5
  29. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  30. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +193 -5
  31. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  32. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  33. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeResources +25 -25
  34. package/package.json +2 -2
  35. package/react-native-attachment-kit.podspec +1 -1
  36. package/src/NativeAttachmentKit.ts +5 -0
  37. package/src/index.ts +118 -7
  38. package/src/types.ts +173 -0
@@ -7,6 +7,7 @@
7
7
 
8
8
  import UIKit
9
9
  import Photos
10
+ import AVFoundation
10
11
  import ImageIO
11
12
  import CoreLocation
12
13
  import RNAttachmentKit
@@ -49,6 +50,13 @@ class RNAttachmentKit: RCTEventEmitter {
49
50
  private var editorTransitionDelegate: ImageEditorTransitionDelegate?
50
51
  /// 열려 있는 미디어 갤러리 (galleryId → 화면)
51
52
  fileprivate var mediaGalleries: [String: MediaGalleryViewController] = [:]
53
+ /// 진행 중인 전송 처리 (processingId → 작업, 메인 스레드에서만). 보내는 중에 갤러리를 다시 열어 또 보낼 수 있어 여러 개일 수 있음
54
+ private var processingJobs: [String: MediaProcessingJob] = [:]
55
+ /// 가장 최근에 시작한 처리 (cancelVideoProcessing 에 processingId 가 없을 때)
56
+ private var lastProcessingId: String?
57
+ /// 동영상 변환 진행 HUD 를 띄울지 — video.showProgressHUD, 없으면 asyncProcessing 이 아닐 때만
58
+ /// (앱이 onProcessingStart / onVideoProgress / onImageProcessed 로 말풍선에 진행 상황을 그리는 구조면 HUD 없이)
59
+ private var galleryShowsVideoHUD = true
52
60
 
53
61
  // MARK: - Module Setup
54
62
 
@@ -66,7 +74,8 @@ class RNAttachmentKit: RCTEventEmitter {
66
74
  override func supportedEvents() -> [String]! {
67
75
  return ["onSelectionChange", "onImageProcessed", "onPageSelected", "onViewerWillClose", "onImageDeleted",
68
76
  "onGalleryItemDeleted", "onGalleryNeedPage", "onGalleryClosed", "onGalleryGridSizeChanged",
69
- "onLocationSelected", "onFilesSelected"]
77
+ "onLocationSelected", "onFilesSelected", "onAIUsage", "onAIChargeRequired", "onVideoProgress",
78
+ "onMapViewerWillClose", "onProcessingStart"]
70
79
  }
71
80
 
72
81
  // MARK: - Public Methods
@@ -270,6 +279,9 @@ class RNAttachmentKit: RCTEventEmitter {
270
279
  vc.sourceImageIsMarkerless = markerless && image != nil
271
280
  vc.sourceRectProvider = { [weak self] in self?.mapViewerSourceRect }
272
281
  }
282
+ vc.onWillClose = { [weak self] dur in
283
+ self?.sendEvent(withName: "onMapViewerWillClose", body: ["durationMs": Int(dur * 1000)])
284
+ }
273
285
  vc.onClosed = { [weak self] in
274
286
  self?.mapViewerSourceRect = nil
275
287
  resolve(nil)
@@ -365,6 +377,20 @@ class RNAttachmentKit: RCTEventEmitter {
365
377
  }
366
378
  }
367
379
 
380
+ /// AI 기능 설정·잔액 (넘긴 값만 바뀜). 사용·충전 요청은 onAIUsage / onAIChargeRequired 이벤트로 알림
381
+ @objc
382
+ func setAIConfig(_ config: NSDictionary,
383
+ resolve: @escaping RCTPromiseResolveBlock,
384
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
385
+ let dict = config as? [String: Any] ?? [:]
386
+ DispatchQueue.main.async {
387
+ AIBilling.apply(dict)
388
+ AIBilling.onUsage = { [weak self] body in self?.sendEvent(withName: "onAIUsage", body: body) }
389
+ AIBilling.onChargeRequired = { [weak self] body in self?.sendEvent(withName: "onAIChargeRequired", body: body) }
390
+ resolve(nil)
391
+ }
392
+ }
393
+
368
394
  @objc
369
395
  func clearReverseGeocodeCache(_ resolve: @escaping RCTPromiseResolveBlock,
370
396
  reject: @escaping RCTPromiseRejectBlock) -> Void {
@@ -393,6 +419,8 @@ class RNAttachmentKit: RCTEventEmitter {
393
419
  let placeholderImages = options["placeholderImages"] as? [String]
394
420
  // cacheFiles: images 와 같은 순서의 로컬 캐시 파일 경로 (null 항목은 NSNull 로 옴 → 빈 문자열)
395
421
  let cacheFiles: [String]? = (options["cacheFiles"] as? [Any])?.map { ($0 as? String) ?? "" }
422
+ // videos: images 와 같은 순서. 항목이 있으면 그 페이지는 동영상 (images[i] 는 포스터)
423
+ let viewerVideos: [ViewerVideoItem?] = (options["videos"] as? [Any])?.map { ViewerVideoItem(dictionary: $0) } ?? []
396
424
  let themeColorHex = options["themeColor"] as? String
397
425
  let languageCode = options["languageCode"] as? String ?? "en"
398
426
  let viewerTitle = options["title"] as? String
@@ -476,6 +504,7 @@ class RNAttachmentKit: RCTEventEmitter {
476
504
  viewerVC.viewerTitle = viewerTitle
477
505
  viewerVC.placeholderImages = placeholderImages
478
506
  viewerVC.cacheFiles = cacheFiles
507
+ viewerVC.videos = viewerVideos
479
508
  if let hex = themeColorHex, let color = UIColor(hexString: hex) {
480
509
  viewerVC.themeColor = color
481
510
  }
@@ -941,6 +970,14 @@ class RNAttachmentKit: RCTEventEmitter {
941
970
  galleryVC.showsAttachMenu = options["showAttachMenu"] as? Bool ?? false
942
971
  // 위치 보내기 때 지도 캡처 이미지 (기본 true). false 면 위치 정보만 — 받는 쪽이 renderLocationThumbnail 로 그림
943
972
  galleryVC.capturesLocationImage = options["captureLocationImage"] as? Bool ?? true
973
+ // 동영상: mediaType "photo"(기본) | "video" | "all" + video 옵션 (지시서 3-1)
974
+ let mediaTypeOption = (options["mediaType"] as? String)?.lowercased() ?? "photo"
975
+ galleryVC.mediaType = ["photo", "video", "all"].contains(mediaTypeOption) ? mediaTypeOption : "photo"
976
+ let videoOptions = VideoExportOptions(dictionary: options["video"] as? [String: Any])
977
+ let asyncProcessing = options["asyncProcessing"] as? Bool ?? false
978
+ self.galleryShowsVideoHUD = ((options["video"] as? [String: Any])?["showProgressHUD"] as? Bool) ?? !asyncProcessing
979
+ galleryVC.maxVideoDurationSec = videoOptions.maxDurationSec
980
+ galleryVC.videoExportOptions = videoOptions
944
981
  // 위치 탭에서 보내기 → onLocationSelected 이벤트로 결과를 넘기고, openGallery 는 빈 배열로 끝냄
945
982
  galleryVC.onLocationSelected = { [weak self, weak galleryVC] location in
946
983
  self?.currentGalleryVC = nil
@@ -965,6 +1002,8 @@ class RNAttachmentKit: RCTEventEmitter {
965
1002
  let galleryMaxWidth = galleryVC.maxWidth
966
1003
  let galleryMaxHeight = galleryVC.maxHeight
967
1004
  let galleryFormat = galleryVC.outputFormat // "webp" | "jpg" | "png"
1005
+ let galleryAccent = galleryVC.parsedThemeColor
1006
+ let galleryLanguage = galleryVC.languageCode
968
1007
  galleryVC.onImagesSelected = { [weak self, weak galleryVC] selectedImages in
969
1008
  guard let self = self else { resolve([]); return }
970
1009
 
@@ -987,16 +1026,126 @@ class RNAttachmentKit: RCTEventEmitter {
987
1026
  // 처리한 이미지는 바로 놓아 주도록 (전체 UIImage 배열을 끝까지 들고 있지 않게) 옵셔널 배열로 옮김
988
1027
  var pending: [(PHAsset?, UIImage)?] = selectedImages.map { $0 }
989
1028
 
1029
+ // 동영상이 섞여 있으면 변환 진행 표시 (갤러리가 닫힌 뒤에도 보이게 키 윈도우에) + 취소
1030
+ let hasVideo = selectedImages.contains { $0.0?.mediaType == .video }
1031
+ // 편집 화면에서 고른 품질·음소거 (없으면 기본 품질)
1032
+ let videoStates = galleryVC?.videoEditStates ?? [:]
1033
+ var hud: VideoExportHUD?
1034
+ var currentExporter: VideoExporter?
1035
+ var cancelledAll = false
1036
+ let exporterLock = NSLock()
1037
+ // 앱이 동영상 하나(또는 전부)를 취소할 수 있게 (cancelVideoProcessing)
1038
+ let job = MediaProcessingJob()
1039
+ let processingId = UUID().uuidString
1040
+ self.processingJobs[processingId] = job
1041
+ self.lastProcessingId = processingId
1042
+ // 처리 시작 이벤트용 — 고른 순서의 원본(사진은 편집 결과) 크기·포스터
1043
+ let startItems = selectedImages.map { ($0.0, $0.1) }
1044
+ if hasVideo && self.galleryShowsVideoHUD {
1045
+ hud = VideoExportHUD.show(accent: galleryAccent, languageCode: galleryLanguage)
1046
+ hud?.onCancel = {
1047
+ exporterLock.lock(); cancelledAll = true; currentExporter?.cancel(); exporterLock.unlock()
1048
+ }
1049
+ }
1050
+
990
1051
  DispatchQueue.global(qos: .userInitiated).async {
1052
+ // ✅ 처리 시작: 모든 자리(사진·동영상)를 먼저 알림 → 앱이 말풍선을 바로 만들고 진행률을 그림
1053
+ self.sendEvent(withName: "onProcessingStart", body: [
1054
+ "processingId": processingId,
1055
+ "total": total,
1056
+ "items": startItems.enumerated().map { i, pair in
1057
+ self.processingStartItem(index: i, asset: pair.0, image: pair.1, videoStates: videoStates,
1058
+ videoOptions: videoOptions, maxWidth: maxWidth, maxHeight: maxHeight,
1059
+ tempDir: tempDir)
1060
+ },
1061
+ ])
991
1062
  for index in 0..<total {
992
1063
  guard let pair = pending[index] else { continue }
993
1064
  pending[index] = nil
1065
+ exporterLock.lock(); let stop = cancelledAll; exporterLock.unlock()
1066
+ if stop { break }
994
1067
  autoreleasepool {
995
1068
  let (asset, image) = pair
996
1069
  let timestamp = Int(Date().timeIntervalSince1970 * 1000)
997
1070
  let originalSize = image.size
998
1071
 
999
- if let phAsset = asset {
1072
+ if let phAsset = asset, phAsset.mediaType == .video {
1073
+ // ✅ 동영상: VideoExporter 로 전송용 mp4 (+ 썸네일·메타). image 는 포스터(목록용)
1074
+ let exporter = VideoExporter()
1075
+ exporterLock.lock(); currentExporter = exporter; exporterLock.unlock()
1076
+ let base = "turbo_video_\(timestamp)_\(index)"
1077
+ var result: [String: Any] = [
1078
+ "originalUri": "ph://\(phAsset.localIdentifier)",
1079
+ "originalWidth": phAsset.pixelWidth,
1080
+ "originalHeight": phAsset.pixelHeight,
1081
+ "mediaType": "video",
1082
+ "type": "video/mp4",
1083
+ "duration": phAsset.duration,
1084
+ ]
1085
+ do {
1086
+ // 앱이 이미 취소한 동영상은 건너뜀 (결과에 cancelled 로)
1087
+ if job.isCancelled(index) { throw VideoExporter.ExportError.cancelled }
1088
+ job.begin(index: index, exporter: exporter)
1089
+ let state = videoStates[phAsset.localIdentifier] ?? VideoEditState.defaultState(for: phAsset, options: videoOptions)
1090
+ let throttle = ProgressThrottle()
1091
+ let onProgress: (Double) -> Void = { p in
1092
+ // 프레임마다 메인으로 보내지 않고 1%·0.1초 단위로만 (HUD 표시는 정수 % 라 같음)
1093
+ guard throttle.shouldSend(p) else { return }
1094
+ DispatchQueue.main.async { hud?.update(progress: p, index: index, total: total) }
1095
+ self.sendEvent(withName: "onVideoProgress", body: ["index": index, "total": total, "progress": p, "processingId": processingId])
1096
+ }
1097
+ // 편집 화면에서 미리 만들어 둔 파일이 있으면(또는 만드는 중이면 끝나고) 그대로 씀
1098
+ let r: VideoExportResult
1099
+ // 기다리는 동안 앱(X)이나 HUD 가 취소하면 바로 멈춤 (전엔 미리 만들기가 끝날 때까지 못 빠져나왔음)
1100
+ let stop = { () -> Bool in
1101
+ exporterLock.lock(); let all = cancelledAll; exporterLock.unlock()
1102
+ return all || job.isCancelled(index)
1103
+ }
1104
+ if let ready = VideoPrepareManager.shared.takeReady(asset: phAsset, state: state, progress: onProgress, isCancelled: stop) {
1105
+ r = ready; onProgress(1)
1106
+ } else {
1107
+ if stop() { throw VideoExporter.ExportError.cancelled }
1108
+ r = try exporter.export(asset: phAsset, state: state,
1109
+ options: videoOptions, outputDir: URL(fileURLWithPath: tempDir), baseName: base, progress: onProgress)
1110
+ }
1111
+ // 미리 만든 파일을 기다리는 동안 취소됐으면 결과를 쓰지 않음
1112
+ if job.isCancelled(index) { throw VideoExporter.ExportError.cancelled }
1113
+ result["uri"] = "file://" + r.url.path
1114
+ result["width"] = r.width
1115
+ result["height"] = r.height
1116
+ result["duration"] = r.duration
1117
+ result["fileSize"] = r.fileSize
1118
+ result["fileName"] = r.url.lastPathComponent
1119
+ result["fileExtension"] = "mp4"
1120
+ result["videoCodec"] = r.codec
1121
+ result["videoBitrate"] = r.bitrate
1122
+ result["edited"] = !r.passthrough
1123
+ if let t = r.thumbnailURL {
1124
+ result["thumbnailUri"] = "file://" + t.path
1125
+ result["thumbnailWidth"] = r.thumbnailWidth
1126
+ result["thumbnailHeight"] = r.thumbnailHeight
1127
+ }
1128
+ } catch VideoExporter.ExportError.cancelled where job.isCancelled(index) {
1129
+ // 앱이 이 동영상만 취소 → 나머지는 계속
1130
+ result["error"] = "CANCELLED"
1131
+ result["cancelled"] = true
1132
+ } catch VideoExporter.ExportError.cancelled {
1133
+ // HUD 의 취소 → 전체 취소 (Promise reject)
1134
+ job.end()
1135
+ exporterLock.lock(); cancelledAll = true; exporterLock.unlock()
1136
+ return
1137
+ } catch {
1138
+ result["error"] = error.localizedDescription
1139
+ }
1140
+ job.end()
1141
+ exporterLock.lock(); currentExporter = nil; exporterLock.unlock()
1142
+
1143
+ resultsLock.lock()
1144
+ results.append(result)
1145
+ let currentResult = result
1146
+ resultsLock.unlock()
1147
+ self.sendEvent(withName: "onImageProcessed", body: ["index": index, "total": total, "image": currentResult, "processingId": processingId])
1148
+ } else if let phAsset = asset {
1000
1149
  // ✅ 1순위: PHAsset
1001
1150
  let originalUri = "ph://\(phAsset.localIdentifier)"
1002
1151
 
@@ -1036,7 +1185,8 @@ class RNAttachmentKit: RCTEventEmitter {
1036
1185
  self.sendEvent(withName: "onImageProcessed", body: [
1037
1186
  "index": index,
1038
1187
  "total": total,
1039
- "image": currentResult
1188
+ "image": currentResult,
1189
+ "processingId": processingId
1040
1190
  ])
1041
1191
  } else {
1042
1192
  // ✅ 2순위: 카메라 촬영본
@@ -1086,17 +1236,30 @@ class RNAttachmentKit: RCTEventEmitter {
1086
1236
  self.sendEvent(withName: "onImageProcessed", body: [
1087
1237
  "index": index,
1088
1238
  "total": total,
1089
- "image": currentResult
1239
+ "image": currentResult,
1240
+ "processingId": processingId
1090
1241
  ])
1091
1242
  }
1092
1243
  } // end autoreleasepool
1093
1244
  } // end for
1094
1245
 
1095
1246
  DispatchQueue.main.async {
1096
- self.currentGalleryVC = nil
1097
- resolve(results)
1098
- // 결과를 넘긴 뒤: 갤러리의 PHCachingImageManager 캐시·콜백 해제 (닫힘 애니메이션에는 영향 없음)
1099
- galleryVC?.releaseAfterReturn()
1247
+ hud?.dismiss()
1248
+ // 처리하는 동안 새 갤러리가 열렸을 수 있음 → 내 갤러리일 때만 비움
1249
+ if self.currentGalleryVC == nil || self.currentGalleryVC === galleryVC { self.currentGalleryVC = nil }
1250
+ self.processingJobs[processingId] = nil
1251
+ exporterLock.lock(); let wasCancelled = cancelledAll; exporterLock.unlock()
1252
+ if wasCancelled {
1253
+ // 변환 취소: 이미 만든 파일은 버림
1254
+ results.compactMap { ($0["uri"] as? String)?.replacingOccurrences(of: "file://", with: "") }
1255
+ .forEach { try? FileManager.default.removeItem(atPath: $0) }
1256
+ reject("CANCELLED", "User cancelled video conversion", nil)
1257
+ } else {
1258
+ resolve(results)
1259
+ }
1260
+ // 결과를 넘긴 뒤: 갤러리의 PHCachingImageManager 캐시·콜백 해제 (닫힘 애니메이션에는 영향 없음).
1261
+ // 새 갤러리가 열려 있으면 그 화면이 미리 인코딩 중인 파일은 건드리지 않음
1262
+ galleryVC?.releaseAfterReturn(discardPreparedVideos: self.currentGalleryVC == nil)
1100
1263
  }
1101
1264
  }
1102
1265
  }
@@ -1150,6 +1313,100 @@ class RNAttachmentKit: RCTEventEmitter {
1150
1313
  }
1151
1314
  }
1152
1315
 
1316
+ /// 동영상 변환 취소 (HUD 없이 앱이 말풍선에서 취소할 때). index 생략(-1) = 남은 동영상 전부.
1317
+ /// 취소된 동영상은 onImageProcessed / 결과에 { error: "CANCELLED", cancelled: true } 로 오고, 나머지는 계속 처리
1318
+ /// processingId 생략("") = 가장 최근 처리
1319
+ @objc(cancelVideoProcessing:processingId:resolve:reject:)
1320
+ func cancelVideoProcessing(_ index: NSNumber, processingId: NSString, resolve: @escaping RCTPromiseResolveBlock,
1321
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
1322
+ DispatchQueue.main.async {
1323
+ let id = processingId.length > 0 ? processingId as String : self.lastProcessingId
1324
+ guard let id = id, let job = self.processingJobs[id] else { resolve(false); return }
1325
+ let i = index.intValue
1326
+ job.cancel(index: i >= 0 ? i : nil)
1327
+ resolve(true)
1328
+ }
1329
+ }
1330
+
1331
+ /// onProcessingStart 의 한 자리: 사진은 예상 결과 크기, 동영상은 예상 결과 크기·길이(구간 반영)·포스터(크롭·그리기 반영 jpg)
1332
+ private func processingStartItem(index: Int, asset: PHAsset?, image: UIImage?, videoStates: [String: VideoEditState],
1333
+ videoOptions: VideoExportOptions, maxWidth: Int, maxHeight: Int, tempDir: String) -> [String: Any] {
1334
+ if let a = asset, a.mediaType == .video {
1335
+ let state = videoStates[a.localIdentifier] ?? VideoEditState.defaultState(for: a, options: videoOptions)
1336
+ let d = CGSize(width: a.pixelWidth, height: a.pixelHeight)
1337
+ let size = VideoExporter.predictedOutputSize(displaySize: d, state: state, options: videoOptions, sourceDuration: a.duration)
1338
+ var item: [String: Any] = [
1339
+ "index": index, "mediaType": "video", "originalUri": "ph://\(a.localIdentifier)",
1340
+ "width": Int(size.width), "height": Int(size.height),
1341
+ "duration": state.outputDuration(sourceDuration: a.duration),
1342
+ ]
1343
+ // 앞을 잘랐으면 포스터는 잘린 구간의 첫 프레임 (원본 첫 프레임은 결과 영상에 없음 — Android 와 같음)
1344
+ let trimmedStart = state.trimRange(duration: a.duration).map { CMTimeGetSeconds($0.start) } ?? 0
1345
+ let base = trimmedStart > 0.01 ? (Self.videoFrame(a, at: trimmedStart) ?? image) : image
1346
+ if let poster = base {
1347
+ let edited = state.applyEdits(to: poster)
1348
+ if let encoded = VideoExporter.encodePoster(edited, format: videoOptions.posterFormat, quality: 0.8) {
1349
+ let path = (tempDir as NSString).appendingPathComponent("turbo_video_poster_\(Int(Date().timeIntervalSince1970 * 1000))_\(index).\(encoded.fileExtension)")
1350
+ if (try? encoded.data.write(to: URL(fileURLWithPath: path))) != nil {
1351
+ item["thumbnailUri"] = "file://" + path
1352
+ item["thumbnailWidth"] = Int(edited.size.width * edited.scale)
1353
+ item["thumbnailHeight"] = Int(edited.size.height * edited.scale)
1354
+ }
1355
+ }
1356
+ }
1357
+ return item
1358
+ }
1359
+ // 사진: 리사이즈 규칙(resizeImage)과 같은 예상 크기 (resizeImage 처럼 image.size 기준)
1360
+ var w = image?.size.width ?? CGFloat(asset?.pixelWidth ?? 0)
1361
+ var h = image?.size.height ?? CGFloat(asset?.pixelHeight ?? 0)
1362
+ if w > 0, h > 0 {
1363
+ var ratio: CGFloat = 1
1364
+ if maxWidth > 0 && maxHeight > 0 { ratio = min(CGFloat(maxWidth) / w, CGFloat(maxHeight) / h, 1) }
1365
+ else if maxWidth > 0 { ratio = min(CGFloat(maxWidth) / w, 1) }
1366
+ else if maxHeight > 0 { ratio = min(CGFloat(maxHeight) / h, 1) }
1367
+ w *= ratio; h *= ratio
1368
+ }
1369
+ var item: [String: Any] = ["index": index, "mediaType": "image", "width": Int(w.rounded()), "height": Int(h.rounded())]
1370
+ if let a = asset { item["originalUri"] = "ph://\(a.localIdentifier)" }
1371
+ return item
1372
+ }
1373
+
1374
+ /// 동영상 포스터 (긴 변 512, 동기) — 처리 시작 이벤트용
1375
+ /// 동영상의 [seconds] 프레임 (긴 변 ≤ 1024, 회전 반영). 백그라운드 스레드에서만 — 원본을 받는 동안 최대 3초 기다림
1376
+ private static func videoFrame(_ asset: PHAsset, at seconds: Double) -> UIImage? {
1377
+ let o = PHVideoRequestOptions()
1378
+ o.isNetworkAccessAllowed = true
1379
+ o.deliveryMode = .automatic
1380
+ var avAsset: AVAsset?
1381
+ let done = DispatchSemaphore(value: 0)
1382
+ PHImageManager.default().requestAVAsset(forVideo: asset, options: o) { a, _, _ in
1383
+ avAsset = a
1384
+ done.signal()
1385
+ }
1386
+ guard done.wait(timeout: .now() + 3) == .success, let av = avAsset else { return nil }
1387
+ let gen = AVAssetImageGenerator(asset: av)
1388
+ gen.appliesPreferredTrackTransform = true
1389
+ gen.maximumSize = CGSize(width: 1024, height: 1024)
1390
+ // 잘린 구간 밖(앞) 프레임이 나오지 않게 — 뒤로만 조금 허용
1391
+ gen.requestedTimeToleranceBefore = .zero
1392
+ gen.requestedTimeToleranceAfter = CMTime(seconds: 0.1, preferredTimescale: 600)
1393
+ guard let cg = try? gen.copyCGImage(at: CMTime(seconds: seconds, preferredTimescale: 600), actualTime: nil) else { return nil }
1394
+ return UIImage(cgImage: cg)
1395
+ }
1396
+
1397
+ private static func requestPoster(_ asset: PHAsset) -> UIImage? {
1398
+ let o = PHImageRequestOptions()
1399
+ o.isSynchronous = true
1400
+ o.deliveryMode = .highQualityFormat
1401
+ o.resizeMode = .fast
1402
+ o.isNetworkAccessAllowed = true
1403
+ var out: UIImage?
1404
+ PHImageManager.default().requestImage(for: asset, targetSize: CGSize(width: 512, height: 512), contentMode: .aspectFit, options: o) { img, _ in
1405
+ out = img
1406
+ }
1407
+ return out
1408
+ }
1409
+
1153
1410
  @objc
1154
1411
  func closeGallery(_ resolve: @escaping RCTPromiseResolveBlock,
1155
1412
  reject: @escaping RCTPromiseRejectBlock) -> Void {
@@ -1457,9 +1714,105 @@ class RNAttachmentKit: RCTEventEmitter {
1457
1714
  }
1458
1715
 
1459
1716
  // MARK: - Editor Result Processing
1717
+
1718
+ /// 동영상 1장을 전송용 mp4 로 만들어 resolve (갤러리 다중 전송 경로의 동영상 분기와 같은 필드)
1719
+ private func exportSingleVideo(_ phAsset: PHAsset, galleryVC: GalleryViewController?, tempDir: String,
1720
+ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
1721
+ let videoOptions = galleryVC?.videoExportOptions ?? VideoExportOptions()
1722
+ let state = galleryVC?.videoEditStates[phAsset.localIdentifier] ?? VideoEditState.defaultState(for: phAsset, options: videoOptions)
1723
+ let hud: VideoExportHUD? = galleryShowsVideoHUD ? VideoExportHUD.show(accent: galleryVC?.parsedThemeColor ?? UIColor(red: 236/255, green: 73/255, blue: 38/255, alpha: 1), languageCode: galleryVC?.languageCode ?? "en") : nil
1724
+ let exporter = VideoExporter()
1725
+ let hudCancelled = AtomicFlag()
1726
+ hud?.onCancel = { hudCancelled.value = true; exporter.cancel() }
1727
+ let job = MediaProcessingJob()
1728
+ let processingId = UUID().uuidString
1729
+ processingJobs[processingId] = job
1730
+ lastProcessingId = processingId
1731
+ DispatchQueue.global(qos: .userInitiated).async {
1732
+ let poster = Self.requestPoster(phAsset)
1733
+ self.sendEvent(withName: "onProcessingStart", body: [
1734
+ "processingId": processingId,
1735
+ "total": 1,
1736
+ "items": [self.processingStartItem(index: 0, asset: phAsset, image: poster, videoStates: [phAsset.localIdentifier: state],
1737
+ videoOptions: videoOptions, maxWidth: 0, maxHeight: 0, tempDir: tempDir)],
1738
+ ])
1739
+ let timestamp = Int(Date().timeIntervalSince1970 * 1000)
1740
+ var result: [String: Any] = [
1741
+ "originalUri": "ph://\(phAsset.localIdentifier)",
1742
+ "originalWidth": phAsset.pixelWidth,
1743
+ "originalHeight": phAsset.pixelHeight,
1744
+ "mediaType": "video",
1745
+ "type": "video/mp4",
1746
+ "duration": phAsset.duration,
1747
+ ]
1748
+ do {
1749
+ job.begin(index: 0, exporter: exporter)
1750
+ let throttle = ProgressThrottle()
1751
+ let onProgress: (Double) -> Void = { p in
1752
+ guard throttle.shouldSend(p) else { return }
1753
+ DispatchQueue.main.async { hud?.update(progress: p, index: 0, total: 1) }
1754
+ self.sendEvent(withName: "onVideoProgress", body: ["index": 0, "total": 1, "progress": p, "processingId": processingId])
1755
+ }
1756
+ let r: VideoExportResult
1757
+ // HUD 취소는 exporter.cancel() — 기다리는 동안엔 job 으로 알 수 없으므로 HUD 취소도 job 에 기록
1758
+ if let ready = VideoPrepareManager.shared.takeReady(asset: phAsset, state: state, progress: onProgress,
1759
+ isCancelled: { job.isCancelled(0) || hudCancelled.value }) {
1760
+ r = ready; onProgress(1)
1761
+ } else {
1762
+ if job.isCancelled(0) || hudCancelled.value { throw VideoExporter.ExportError.cancelled }
1763
+ r = try exporter.export(asset: phAsset, state: state, options: videoOptions,
1764
+ outputDir: URL(fileURLWithPath: tempDir), baseName: "turbo_video_\(timestamp)_0", progress: onProgress)
1765
+ }
1766
+ result["uri"] = "file://" + r.url.path
1767
+ result["width"] = r.width
1768
+ result["height"] = r.height
1769
+ result["duration"] = r.duration
1770
+ result["fileSize"] = r.fileSize
1771
+ result["fileName"] = r.url.lastPathComponent
1772
+ result["fileExtension"] = "mp4"
1773
+ result["videoCodec"] = r.codec
1774
+ result["videoBitrate"] = r.bitrate
1775
+ result["edited"] = !r.passthrough
1776
+ if let t = r.thumbnailURL {
1777
+ result["thumbnailUri"] = "file://" + t.path
1778
+ result["thumbnailWidth"] = r.thumbnailWidth
1779
+ result["thumbnailHeight"] = r.thumbnailHeight
1780
+ }
1781
+ if job.isCancelled(0) { throw VideoExporter.ExportError.cancelled }
1782
+ } catch VideoExporter.ExportError.cancelled where job.isCancelled(0) {
1783
+ // 앱이 취소 (cancelVideoProcessing) — 이벤트·결과에 cancelled 로
1784
+ result["error"] = "CANCELLED"
1785
+ result["cancelled"] = true
1786
+ } catch VideoExporter.ExportError.cancelled {
1787
+ job.end()
1788
+ DispatchQueue.main.async {
1789
+ self.processingJobs[processingId] = nil
1790
+ hud?.dismiss(); reject("CANCELLED", "Video export cancelled", nil)
1791
+ }
1792
+ return
1793
+ } catch {
1794
+ result["error"] = error.localizedDescription
1795
+ }
1796
+ job.end()
1797
+ self.sendEvent(withName: "onImageProcessed", body: ["index": 0, "total": 1, "image": result, "processingId": processingId])
1798
+ DispatchQueue.main.async {
1799
+ hud?.dismiss()
1800
+ if self.currentGalleryVC == nil || self.currentGalleryVC === galleryVC { self.currentGalleryVC = nil }
1801
+ self.processingJobs[processingId] = nil
1802
+ galleryVC?.releaseAfterReturn(discardPreparedVideos: self.currentGalleryVC == nil)
1803
+ resolve([result])
1804
+ }
1805
+ }
1806
+ }
1807
+
1460
1808
 
1461
1809
  private func processEditorResult(asset: PHAsset?, croppedImage: UIImage?, filterState: FilterState, caption: String, galleryVC: GalleryViewController?, targetMaxWidth: Int? = nil, targetMaxHeight: Int? = nil, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
1462
1810
  let tempDir = prepareCacheDirectory()
1811
+ // 동영상 1장 편집 → 전송: 편집 화면에서 고른 품질·음소거로 VideoExporter (사진 경로와 같은 결과 형태)
1812
+ if let phAsset = asset, phAsset.mediaType == .video {
1813
+ exportSingleVideo(phAsset, galleryVC: galleryVC, tempDir: tempDir, resolve: resolve, reject: reject)
1814
+ return
1815
+ }
1463
1816
  let maxWidth = targetMaxWidth ?? galleryVC?.maxWidth ?? 1024
1464
1817
  let maxHeight = targetMaxHeight ?? galleryVC?.maxHeight ?? 1024
1465
1818
  let format = galleryVC?.outputFormat ?? "webp"
@@ -1906,6 +2259,7 @@ fileprivate extension RNAttachmentKit {
1906
2259
 
1907
2260
  editorVC.onEditDeleted = { [weak currentGallery] asset in
1908
2261
  currentGallery?.editedImages.removeValue(forKey: asset.localIdentifier)
2262
+ currentGallery?.videoEditStates.removeValue(forKey: asset.localIdentifier) // 동영상 편집도 기본으로 돌아간 것
1909
2263
  }
1910
2264
 
1911
2265
  editorVC.modalPresentationStyle = .overFullScreen
@@ -1921,7 +2275,22 @@ fileprivate extension RNAttachmentKit {
1921
2275
  }
1922
2276
  }
1923
2277
  editorVC.croppedImages = initialCroppedImages
2278
+ // 동영상 페이지: 품질 단계·용량 상한 + 갤러리가 들고 있는 상태. 바뀌면 바로 갤러리에 반영 (전송 때 VideoExporter 가 읽음)
2279
+ editorVC.videoOptions = VideoExportOptions(dictionary: options["video"] as? [String: Any])
2280
+ var initialVideoStates: [Int: VideoEditState] = [:]
2281
+ for (i, a) in editorAssets.enumerated() {
2282
+ if let st = currentGallery.videoEditStates[a.localIdentifier] { initialVideoStates[i] = st }
2283
+ }
2284
+ editorVC.videoEditStates = initialVideoStates
2285
+ editorVC.onVideoStateChanged = { [weak currentGallery] asset, state in
2286
+ currentGallery?.videoEditStates[asset.localIdentifier] = state
2287
+ }
1924
2288
 
2289
+ if sourceFrame == .zero {
2290
+ // 기준 칸이 없을 때(칸이 화면 밖 등): 기본 전환(아래로 미끄러짐)은 끌어내려 닫을 때 위로 치워 둔 상단 바가
2291
+ // 화면과 함께 내려오며 보이므로 페이드로
2292
+ editorVC.modalTransitionStyle = .crossDissolve
2293
+ }
1925
2294
  if sourceFrame != .zero {
1926
2295
  self.editorTransitionDelegate = ImageEditorTransitionDelegate()
1927
2296
  self.editorTransitionDelegate?.sourceFrame = sourceFrame
@@ -1961,7 +2330,10 @@ fileprivate extension RNAttachmentKit {
1961
2330
  // (호출 중인 객체이므로 다음 런루프에서)
1962
2331
  DispatchQueue.main.async { self?.editorTransitionDelegate = nil }
1963
2332
  }
1964
- currentGallery.setHiddenAsset(asset)
2333
+ // 칸은 열기 애니메이션의 첫 프레임에 비움 (누르자마자 비우면 편집 화면이 준비되는 동안 빈 칸이 번쩍임)
2334
+ self.editorTransitionDelegate?.onPresentationStarted = { [weak currentGallery] in
2335
+ currentGallery?.setHiddenAsset(asset)
2336
+ }
1965
2337
 
1966
2338
  editorVC.transitioningDelegate = self.editorTransitionDelegate
1967
2339
  }
@@ -2085,3 +2457,69 @@ extension RNAttachmentKit: UIViewControllerTransitioningDelegate {
2085
2457
  return zoomAnimator
2086
2458
  }
2087
2459
  }
2460
+
2461
+ // MARK: - 전송 처리 상태 (앱에서 동영상 취소)
2462
+
2463
+ /// openGallery 결과 처리 한 번. cancelVideoProcessing(index) → 그 동영상만(또는 전부) 취소
2464
+ private final class MediaProcessingJob {
2465
+ private let lock = NSLock()
2466
+ private var cancelAll = false
2467
+ private var cancelled = Set<Int>()
2468
+ private var currentIndex: Int?
2469
+ private var currentExporter: VideoExporter?
2470
+
2471
+ func cancel(index: Int?) {
2472
+ lock.lock(); defer { lock.unlock() }
2473
+ if let i = index {
2474
+ cancelled.insert(i)
2475
+ if currentIndex == i { currentExporter?.cancel() }
2476
+ } else {
2477
+ cancelAll = true
2478
+ currentExporter?.cancel()
2479
+ }
2480
+ }
2481
+
2482
+ func isCancelled(_ i: Int) -> Bool {
2483
+ lock.lock(); defer { lock.unlock() }
2484
+ return cancelAll || cancelled.contains(i)
2485
+ }
2486
+
2487
+ func begin(index: Int, exporter: VideoExporter) {
2488
+ lock.lock(); defer { lock.unlock() }
2489
+ currentIndex = index
2490
+ currentExporter = exporter
2491
+ }
2492
+
2493
+ func end() {
2494
+ lock.lock(); defer { lock.unlock() }
2495
+ currentIndex = nil
2496
+ currentExporter = nil
2497
+ }
2498
+ }
2499
+
2500
+ /// onVideoProgress 를 1% 이상 바뀌고 0.1초 지났을 때만 (브릿지가 넘치지 않게). 0 과 1 은 항상
2501
+ private final class ProgressThrottle {
2502
+ private let lock = NSLock()
2503
+ private var last: Double = -1
2504
+ private var lastTime: CFTimeInterval = 0
2505
+
2506
+ func shouldSend(_ p: Double) -> Bool {
2507
+ lock.lock(); defer { lock.unlock() }
2508
+ let now = CACurrentMediaTime()
2509
+ guard p >= 1 || last < 0 || (p - last >= 0.01 && now - lastTime >= 0.1) else { return false }
2510
+ if p >= 1 && last >= 1 { return false }
2511
+ last = p
2512
+ lastTime = now
2513
+ return true
2514
+ }
2515
+ }
2516
+
2517
+ /// 스레드 사이에서 읽고 쓰는 Bool
2518
+ private final class AtomicFlag {
2519
+ private let lock = NSLock()
2520
+ private var v = false
2521
+ var value: Bool {
2522
+ get { lock.lock(); defer { lock.unlock() }; return v }
2523
+ set { lock.lock(); v = newValue; lock.unlock() }
2524
+ }
2525
+ }
@@ -593,6 +593,12 @@ SWIFT_CLASS("_TtC15RNAttachmentKit31RemoteImageViewerViewController")
593
593
  @interface RemoteImageViewerViewController : UIViewController
594
594
  - (nullable instancetype)initWithCoder:(NSCoder * _Nonnull)coder SWIFT_UNAVAILABLE;
595
595
  - (void)viewDidLoad;
596
+ - (void)viewDidAppear:(BOOL)animated;
597
+ - (void)viewWillDisappear:(BOOL)animated;
598
+ @property (nonatomic, readonly) BOOL prefersStatusBarHidden;
599
+ @property (nonatomic, readonly) UIStatusBarAnimation preferredStatusBarUpdateAnimation;
600
+ @property (nonatomic, readonly) BOOL prefersHomeIndicatorAutoHidden;
601
+ - (void)viewDidDisappear:(BOOL)animated;
596
602
  - (void)viewDidLayoutSubviews;
597
603
  - (nonnull instancetype)initWithNibName:(NSString * _Nullable)nibNameOrNil bundle:(NSBundle * _Nullable)nibBundleOrNil SWIFT_UNAVAILABLE;
598
604
  @end
@@ -602,17 +608,18 @@ SWIFT_CLASS("_TtC15RNAttachmentKit31RemoteImageViewerViewController")
602
608
  - (BOOL)gestureRecognizer:(UIGestureRecognizer * _Nonnull)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer * _Nonnull)otherGestureRecognizer SWIFT_WARN_UNUSED_RESULT;
603
609
  @end
604
610
 
605
- @interface RemoteImageViewerViewController (SWIFT_EXTENSION(RNAttachmentKit)) <UIScrollViewDelegate>
606
- - (void)scrollViewDidScroll:(UIScrollView * _Nonnull)scrollView;
607
- - (void)scrollViewDidEndDecelerating:(UIScrollView * _Nonnull)scrollView;
608
- @end
609
-
610
611
  @interface RemoteImageViewerViewController (SWIFT_EXTENSION(RNAttachmentKit)) <UICollectionViewDataSource, UICollectionViewDelegate>
611
612
  - (NSInteger)collectionView:(UICollectionView * _Nonnull)collectionView numberOfItemsInSection:(NSInteger)section SWIFT_WARN_UNUSED_RESULT;
612
613
  - (UICollectionViewCell * _Nonnull)collectionView:(UICollectionView * _Nonnull)collectionView cellForItemAtIndexPath:(NSIndexPath * _Nonnull)indexPath SWIFT_WARN_UNUSED_RESULT;
613
614
  - (void)collectionView:(UICollectionView * _Nonnull)collectionView didSelectItemAtIndexPath:(NSIndexPath * _Nonnull)indexPath;
614
615
  @end
615
616
 
617
+ @interface RemoteImageViewerViewController (SWIFT_EXTENSION(RNAttachmentKit)) <UIScrollViewDelegate>
618
+ - (void)scrollViewDidScroll:(UIScrollView * _Nonnull)scrollView;
619
+ - (void)scrollViewDidEndDragging:(UIScrollView * _Nonnull)scrollView willDecelerate:(BOOL)decelerate;
620
+ - (void)scrollViewDidEndDecelerating:(UIScrollView * _Nonnull)scrollView;
621
+ @end
622
+
616
623
  @class UICollectionViewLayoutAttributes;
617
624
  SWIFT_CLASS("_TtC15RNAttachmentKit21TelegramGalleryLayout")
618
625
  @interface TelegramGalleryLayout : UICollectionViewLayout
@@ -626,6 +633,15 @@ SWIFT_CLASS("_TtC15RNAttachmentKit21TelegramGalleryLayout")
626
633
  - (nullable instancetype)initWithCoder:(NSCoder * _Nonnull)coder OBJC_DESIGNATED_INITIALIZER;
627
634
  @end
628
635
 
636
+ /// 동영상 변환 중 화면 전체를 덮는 진행 표시 (원형 진행률 + “1/3” + 취소). 갤러리가 닫힌 뒤에도 보여야 해서 키 윈도우에 얹음
637
+ SWIFT_CLASS("_TtC15RNAttachmentKit14VideoExportHUD")
638
+ @interface VideoExportHUD : UIView
639
+ - (nullable instancetype)initWithCoder:(NSCoder * _Nonnull)coder SWIFT_UNAVAILABLE;
640
+ - (void)layoutSubviews;
641
+ - (void)willMoveToSuperview:(UIView * _Nullable)newSuperview;
642
+ - (nonnull instancetype)initWithFrame:(CGRect)frame SWIFT_UNAVAILABLE;
643
+ @end
644
+
629
645
  @protocol UIViewControllerContextTransitioning;
630
646
  SWIFT_CLASS("_TtC15RNAttachmentKit22ZoomTransitionAnimator")
631
647
  @interface ZoomTransitionAnimator : NSObject <UIViewControllerAnimatedTransitioning>