react-native-attachment-kit 1.0.5 → 1.0.6

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 (39) hide show
  1. package/CHANGELOG.md +10 -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 +421 -9
  9. package/ios/RNAttachmentKit.xcframework/Info.plist +5 -5
  10. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Assets.car +0 -0
  11. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Headers/RNAttachmentKit-Swift.h +21 -5
  12. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Info.plist +0 -0
  13. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.abi.json +33275 -7562
  14. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +193 -5
  15. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  16. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftinterface +193 -5
  17. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  18. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  19. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeResources +15 -15
  20. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeSignature +0 -0
  21. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Assets.car +0 -0
  22. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Headers/RNAttachmentKit-Swift.h +42 -10
  23. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Info.plist +0 -0
  24. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +33275 -7562
  25. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +193 -5
  26. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  27. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftinterface +193 -5
  28. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +33275 -7562
  29. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +193 -5
  30. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  31. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +193 -5
  32. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  33. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  34. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeResources +25 -25
  35. package/package.json +1 -1
  36. package/react-native-attachment-kit.podspec +1 -1
  37. package/src/NativeAttachmentKit.ts +5 -0
  38. package/src/index.ts +118 -7
  39. package/src/types.ts +173 -0
@@ -49,6 +49,13 @@ class RNAttachmentKit: RCTEventEmitter {
49
49
  private var editorTransitionDelegate: ImageEditorTransitionDelegate?
50
50
  /// 열려 있는 미디어 갤러리 (galleryId → 화면)
51
51
  fileprivate var mediaGalleries: [String: MediaGalleryViewController] = [:]
52
+ /// 진행 중인 전송 처리 (processingId → 작업, 메인 스레드에서만). 보내는 중에 갤러리를 다시 열어 또 보낼 수 있어 여러 개일 수 있음
53
+ private var processingJobs: [String: MediaProcessingJob] = [:]
54
+ /// 가장 최근에 시작한 처리 (cancelVideoProcessing 에 processingId 가 없을 때)
55
+ private var lastProcessingId: String?
56
+ /// 동영상 변환 진행 HUD 를 띄울지 — video.showProgressHUD, 없으면 asyncProcessing 이 아닐 때만
57
+ /// (앱이 onProcessingStart / onVideoProgress / onImageProcessed 로 말풍선에 진행 상황을 그리는 구조면 HUD 없이)
58
+ private var galleryShowsVideoHUD = true
52
59
 
53
60
  // MARK: - Module Setup
54
61
 
@@ -66,7 +73,8 @@ class RNAttachmentKit: RCTEventEmitter {
66
73
  override func supportedEvents() -> [String]! {
67
74
  return ["onSelectionChange", "onImageProcessed", "onPageSelected", "onViewerWillClose", "onImageDeleted",
68
75
  "onGalleryItemDeleted", "onGalleryNeedPage", "onGalleryClosed", "onGalleryGridSizeChanged",
69
- "onLocationSelected", "onFilesSelected"]
76
+ "onLocationSelected", "onFilesSelected", "onAIUsage", "onAIChargeRequired", "onVideoProgress",
77
+ "onMapViewerWillClose", "onProcessingStart"]
70
78
  }
71
79
 
72
80
  // MARK: - Public Methods
@@ -270,6 +278,9 @@ class RNAttachmentKit: RCTEventEmitter {
270
278
  vc.sourceImageIsMarkerless = markerless && image != nil
271
279
  vc.sourceRectProvider = { [weak self] in self?.mapViewerSourceRect }
272
280
  }
281
+ vc.onWillClose = { [weak self] dur in
282
+ self?.sendEvent(withName: "onMapViewerWillClose", body: ["durationMs": Int(dur * 1000)])
283
+ }
273
284
  vc.onClosed = { [weak self] in
274
285
  self?.mapViewerSourceRect = nil
275
286
  resolve(nil)
@@ -365,6 +376,20 @@ class RNAttachmentKit: RCTEventEmitter {
365
376
  }
366
377
  }
367
378
 
379
+ /// AI 기능 설정·잔액 (넘긴 값만 바뀜). 사용·충전 요청은 onAIUsage / onAIChargeRequired 이벤트로 알림
380
+ @objc
381
+ func setAIConfig(_ config: NSDictionary,
382
+ resolve: @escaping RCTPromiseResolveBlock,
383
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
384
+ let dict = config as? [String: Any] ?? [:]
385
+ DispatchQueue.main.async {
386
+ AIBilling.apply(dict)
387
+ AIBilling.onUsage = { [weak self] body in self?.sendEvent(withName: "onAIUsage", body: body) }
388
+ AIBilling.onChargeRequired = { [weak self] body in self?.sendEvent(withName: "onAIChargeRequired", body: body) }
389
+ resolve(nil)
390
+ }
391
+ }
392
+
368
393
  @objc
369
394
  func clearReverseGeocodeCache(_ resolve: @escaping RCTPromiseResolveBlock,
370
395
  reject: @escaping RCTPromiseRejectBlock) -> Void {
@@ -393,6 +418,8 @@ class RNAttachmentKit: RCTEventEmitter {
393
418
  let placeholderImages = options["placeholderImages"] as? [String]
394
419
  // cacheFiles: images 와 같은 순서의 로컬 캐시 파일 경로 (null 항목은 NSNull 로 옴 → 빈 문자열)
395
420
  let cacheFiles: [String]? = (options["cacheFiles"] as? [Any])?.map { ($0 as? String) ?? "" }
421
+ // videos: images 와 같은 순서. 항목이 있으면 그 페이지는 동영상 (images[i] 는 포스터)
422
+ let viewerVideos: [ViewerVideoItem?] = (options["videos"] as? [Any])?.map { ViewerVideoItem(dictionary: $0) } ?? []
396
423
  let themeColorHex = options["themeColor"] as? String
397
424
  let languageCode = options["languageCode"] as? String ?? "en"
398
425
  let viewerTitle = options["title"] as? String
@@ -476,6 +503,7 @@ class RNAttachmentKit: RCTEventEmitter {
476
503
  viewerVC.viewerTitle = viewerTitle
477
504
  viewerVC.placeholderImages = placeholderImages
478
505
  viewerVC.cacheFiles = cacheFiles
506
+ viewerVC.videos = viewerVideos
479
507
  if let hex = themeColorHex, let color = UIColor(hexString: hex) {
480
508
  viewerVC.themeColor = color
481
509
  }
@@ -941,6 +969,14 @@ class RNAttachmentKit: RCTEventEmitter {
941
969
  galleryVC.showsAttachMenu = options["showAttachMenu"] as? Bool ?? false
942
970
  // 위치 보내기 때 지도 캡처 이미지 (기본 true). false 면 위치 정보만 — 받는 쪽이 renderLocationThumbnail 로 그림
943
971
  galleryVC.capturesLocationImage = options["captureLocationImage"] as? Bool ?? true
972
+ // 동영상: mediaType "photo"(기본) | "video" | "all" + video 옵션 (지시서 3-1)
973
+ let mediaTypeOption = (options["mediaType"] as? String)?.lowercased() ?? "photo"
974
+ galleryVC.mediaType = ["photo", "video", "all"].contains(mediaTypeOption) ? mediaTypeOption : "photo"
975
+ let videoOptions = VideoExportOptions(dictionary: options["video"] as? [String: Any])
976
+ let asyncProcessing = options["asyncProcessing"] as? Bool ?? false
977
+ self.galleryShowsVideoHUD = ((options["video"] as? [String: Any])?["showProgressHUD"] as? Bool) ?? !asyncProcessing
978
+ galleryVC.maxVideoDurationSec = videoOptions.maxDurationSec
979
+ galleryVC.videoExportOptions = videoOptions
944
980
  // 위치 탭에서 보내기 → onLocationSelected 이벤트로 결과를 넘기고, openGallery 는 빈 배열로 끝냄
945
981
  galleryVC.onLocationSelected = { [weak self, weak galleryVC] location in
946
982
  self?.currentGalleryVC = nil
@@ -965,6 +1001,8 @@ class RNAttachmentKit: RCTEventEmitter {
965
1001
  let galleryMaxWidth = galleryVC.maxWidth
966
1002
  let galleryMaxHeight = galleryVC.maxHeight
967
1003
  let galleryFormat = galleryVC.outputFormat // "webp" | "jpg" | "png"
1004
+ let galleryAccent = galleryVC.parsedThemeColor
1005
+ let galleryLanguage = galleryVC.languageCode
968
1006
  galleryVC.onImagesSelected = { [weak self, weak galleryVC] selectedImages in
969
1007
  guard let self = self else { resolve([]); return }
970
1008
 
@@ -987,16 +1025,126 @@ class RNAttachmentKit: RCTEventEmitter {
987
1025
  // 처리한 이미지는 바로 놓아 주도록 (전체 UIImage 배열을 끝까지 들고 있지 않게) 옵셔널 배열로 옮김
988
1026
  var pending: [(PHAsset?, UIImage)?] = selectedImages.map { $0 }
989
1027
 
1028
+ // 동영상이 섞여 있으면 변환 진행 표시 (갤러리가 닫힌 뒤에도 보이게 키 윈도우에) + 취소
1029
+ let hasVideo = selectedImages.contains { $0.0?.mediaType == .video }
1030
+ // 편집 화면에서 고른 품질·음소거 (없으면 기본 품질)
1031
+ let videoStates = galleryVC?.videoEditStates ?? [:]
1032
+ var hud: VideoExportHUD?
1033
+ var currentExporter: VideoExporter?
1034
+ var cancelledAll = false
1035
+ let exporterLock = NSLock()
1036
+ // 앱이 동영상 하나(또는 전부)를 취소할 수 있게 (cancelVideoProcessing)
1037
+ let job = MediaProcessingJob()
1038
+ let processingId = UUID().uuidString
1039
+ self.processingJobs[processingId] = job
1040
+ self.lastProcessingId = processingId
1041
+ // 처리 시작 이벤트용 — 고른 순서의 원본(사진은 편집 결과) 크기·포스터
1042
+ let startItems = selectedImages.map { ($0.0, $0.1) }
1043
+ if hasVideo && self.galleryShowsVideoHUD {
1044
+ hud = VideoExportHUD.show(accent: galleryAccent, languageCode: galleryLanguage)
1045
+ hud?.onCancel = {
1046
+ exporterLock.lock(); cancelledAll = true; currentExporter?.cancel(); exporterLock.unlock()
1047
+ }
1048
+ }
1049
+
990
1050
  DispatchQueue.global(qos: .userInitiated).async {
1051
+ // ✅ 처리 시작: 모든 자리(사진·동영상)를 먼저 알림 → 앱이 말풍선을 바로 만들고 진행률을 그림
1052
+ self.sendEvent(withName: "onProcessingStart", body: [
1053
+ "processingId": processingId,
1054
+ "total": total,
1055
+ "items": startItems.enumerated().map { i, pair in
1056
+ self.processingStartItem(index: i, asset: pair.0, image: pair.1, videoStates: videoStates,
1057
+ videoOptions: videoOptions, maxWidth: maxWidth, maxHeight: maxHeight,
1058
+ tempDir: tempDir)
1059
+ },
1060
+ ])
991
1061
  for index in 0..<total {
992
1062
  guard let pair = pending[index] else { continue }
993
1063
  pending[index] = nil
1064
+ exporterLock.lock(); let stop = cancelledAll; exporterLock.unlock()
1065
+ if stop { break }
994
1066
  autoreleasepool {
995
1067
  let (asset, image) = pair
996
1068
  let timestamp = Int(Date().timeIntervalSince1970 * 1000)
997
1069
  let originalSize = image.size
998
1070
 
999
- if let phAsset = asset {
1071
+ if let phAsset = asset, phAsset.mediaType == .video {
1072
+ // ✅ 동영상: VideoExporter 로 전송용 mp4 (+ 썸네일·메타). image 는 포스터(목록용)
1073
+ let exporter = VideoExporter()
1074
+ exporterLock.lock(); currentExporter = exporter; exporterLock.unlock()
1075
+ let base = "turbo_video_\(timestamp)_\(index)"
1076
+ var result: [String: Any] = [
1077
+ "originalUri": "ph://\(phAsset.localIdentifier)",
1078
+ "originalWidth": phAsset.pixelWidth,
1079
+ "originalHeight": phAsset.pixelHeight,
1080
+ "mediaType": "video",
1081
+ "type": "video/mp4",
1082
+ "duration": phAsset.duration,
1083
+ ]
1084
+ do {
1085
+ // 앱이 이미 취소한 동영상은 건너뜀 (결과에 cancelled 로)
1086
+ if job.isCancelled(index) { throw VideoExporter.ExportError.cancelled }
1087
+ job.begin(index: index, exporter: exporter)
1088
+ let state = videoStates[phAsset.localIdentifier] ?? VideoEditState.defaultState(for: phAsset, options: videoOptions)
1089
+ let throttle = ProgressThrottle()
1090
+ let onProgress: (Double) -> Void = { p in
1091
+ // 프레임마다 메인으로 보내지 않고 1%·0.1초 단위로만 (HUD 표시는 정수 % 라 같음)
1092
+ guard throttle.shouldSend(p) else { return }
1093
+ DispatchQueue.main.async { hud?.update(progress: p, index: index, total: total) }
1094
+ self.sendEvent(withName: "onVideoProgress", body: ["index": index, "total": total, "progress": p, "processingId": processingId])
1095
+ }
1096
+ // 편집 화면에서 미리 만들어 둔 파일이 있으면(또는 만드는 중이면 끝나고) 그대로 씀
1097
+ let r: VideoExportResult
1098
+ // 기다리는 동안 앱(X)이나 HUD 가 취소하면 바로 멈춤 (전엔 미리 만들기가 끝날 때까지 못 빠져나왔음)
1099
+ let stop = { () -> Bool in
1100
+ exporterLock.lock(); let all = cancelledAll; exporterLock.unlock()
1101
+ return all || job.isCancelled(index)
1102
+ }
1103
+ if let ready = VideoPrepareManager.shared.takeReady(asset: phAsset, state: state, progress: onProgress, isCancelled: stop) {
1104
+ r = ready; onProgress(1)
1105
+ } else {
1106
+ if stop() { throw VideoExporter.ExportError.cancelled }
1107
+ r = try exporter.export(asset: phAsset, state: state,
1108
+ options: videoOptions, outputDir: URL(fileURLWithPath: tempDir), baseName: base, progress: onProgress)
1109
+ }
1110
+ // 미리 만든 파일을 기다리는 동안 취소됐으면 결과를 쓰지 않음
1111
+ if job.isCancelled(index) { throw VideoExporter.ExportError.cancelled }
1112
+ result["uri"] = "file://" + r.url.path
1113
+ result["width"] = r.width
1114
+ result["height"] = r.height
1115
+ result["duration"] = r.duration
1116
+ result["fileSize"] = r.fileSize
1117
+ result["fileName"] = r.url.lastPathComponent
1118
+ result["fileExtension"] = "mp4"
1119
+ result["videoCodec"] = r.codec
1120
+ result["videoBitrate"] = r.bitrate
1121
+ result["edited"] = !r.passthrough
1122
+ if let t = r.thumbnailURL {
1123
+ result["thumbnailUri"] = "file://" + t.path
1124
+ result["thumbnailWidth"] = r.thumbnailWidth
1125
+ result["thumbnailHeight"] = r.thumbnailHeight
1126
+ }
1127
+ } catch VideoExporter.ExportError.cancelled where job.isCancelled(index) {
1128
+ // 앱이 이 동영상만 취소 → 나머지는 계속
1129
+ result["error"] = "CANCELLED"
1130
+ result["cancelled"] = true
1131
+ } catch VideoExporter.ExportError.cancelled {
1132
+ // HUD 의 취소 → 전체 취소 (Promise reject)
1133
+ job.end()
1134
+ exporterLock.lock(); cancelledAll = true; exporterLock.unlock()
1135
+ return
1136
+ } catch {
1137
+ result["error"] = error.localizedDescription
1138
+ }
1139
+ job.end()
1140
+ exporterLock.lock(); currentExporter = nil; exporterLock.unlock()
1141
+
1142
+ resultsLock.lock()
1143
+ results.append(result)
1144
+ let currentResult = result
1145
+ resultsLock.unlock()
1146
+ self.sendEvent(withName: "onImageProcessed", body: ["index": index, "total": total, "image": currentResult, "processingId": processingId])
1147
+ } else if let phAsset = asset {
1000
1148
  // ✅ 1순위: PHAsset
1001
1149
  let originalUri = "ph://\(phAsset.localIdentifier)"
1002
1150
 
@@ -1036,7 +1184,8 @@ class RNAttachmentKit: RCTEventEmitter {
1036
1184
  self.sendEvent(withName: "onImageProcessed", body: [
1037
1185
  "index": index,
1038
1186
  "total": total,
1039
- "image": currentResult
1187
+ "image": currentResult,
1188
+ "processingId": processingId
1040
1189
  ])
1041
1190
  } else {
1042
1191
  // ✅ 2순위: 카메라 촬영본
@@ -1086,17 +1235,30 @@ class RNAttachmentKit: RCTEventEmitter {
1086
1235
  self.sendEvent(withName: "onImageProcessed", body: [
1087
1236
  "index": index,
1088
1237
  "total": total,
1089
- "image": currentResult
1238
+ "image": currentResult,
1239
+ "processingId": processingId
1090
1240
  ])
1091
1241
  }
1092
1242
  } // end autoreleasepool
1093
1243
  } // end for
1094
1244
 
1095
1245
  DispatchQueue.main.async {
1096
- self.currentGalleryVC = nil
1097
- resolve(results)
1098
- // 결과를 넘긴 뒤: 갤러리의 PHCachingImageManager 캐시·콜백 해제 (닫힘 애니메이션에는 영향 없음)
1099
- galleryVC?.releaseAfterReturn()
1246
+ hud?.dismiss()
1247
+ // 처리하는 동안 새 갤러리가 열렸을 수 있음 → 내 갤러리일 때만 비움
1248
+ if self.currentGalleryVC == nil || self.currentGalleryVC === galleryVC { self.currentGalleryVC = nil }
1249
+ self.processingJobs[processingId] = nil
1250
+ exporterLock.lock(); let wasCancelled = cancelledAll; exporterLock.unlock()
1251
+ if wasCancelled {
1252
+ // 변환 취소: 이미 만든 파일은 버림
1253
+ results.compactMap { ($0["uri"] as? String)?.replacingOccurrences(of: "file://", with: "") }
1254
+ .forEach { try? FileManager.default.removeItem(atPath: $0) }
1255
+ reject("CANCELLED", "User cancelled video conversion", nil)
1256
+ } else {
1257
+ resolve(results)
1258
+ }
1259
+ // 결과를 넘긴 뒤: 갤러리의 PHCachingImageManager 캐시·콜백 해제 (닫힘 애니메이션에는 영향 없음).
1260
+ // 새 갤러리가 열려 있으면 그 화면이 미리 인코딩 중인 파일은 건드리지 않음
1261
+ galleryVC?.releaseAfterReturn(discardPreparedVideos: self.currentGalleryVC == nil)
1100
1262
  }
1101
1263
  }
1102
1264
  }
@@ -1150,6 +1312,75 @@ class RNAttachmentKit: RCTEventEmitter {
1150
1312
  }
1151
1313
  }
1152
1314
 
1315
+ /// 동영상 변환 취소 (HUD 없이 앱이 말풍선에서 취소할 때). index 생략(-1) = 남은 동영상 전부.
1316
+ /// 취소된 동영상은 onImageProcessed / 결과에 { error: "CANCELLED", cancelled: true } 로 오고, 나머지는 계속 처리
1317
+ /// processingId 생략("") = 가장 최근 처리
1318
+ @objc(cancelVideoProcessing:processingId:resolve:reject:)
1319
+ func cancelVideoProcessing(_ index: NSNumber, processingId: NSString, resolve: @escaping RCTPromiseResolveBlock,
1320
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
1321
+ DispatchQueue.main.async {
1322
+ let id = processingId.length > 0 ? processingId as String : self.lastProcessingId
1323
+ guard let id = id, let job = self.processingJobs[id] else { resolve(false); return }
1324
+ let i = index.intValue
1325
+ job.cancel(index: i >= 0 ? i : nil)
1326
+ resolve(true)
1327
+ }
1328
+ }
1329
+
1330
+ /// onProcessingStart 의 한 자리: 사진은 예상 결과 크기, 동영상은 예상 결과 크기·길이(구간 반영)·포스터(크롭·그리기 반영 jpg)
1331
+ private func processingStartItem(index: Int, asset: PHAsset?, image: UIImage?, videoStates: [String: VideoEditState],
1332
+ videoOptions: VideoExportOptions, maxWidth: Int, maxHeight: Int, tempDir: String) -> [String: Any] {
1333
+ if let a = asset, a.mediaType == .video {
1334
+ let state = videoStates[a.localIdentifier] ?? VideoEditState.defaultState(for: a, options: videoOptions)
1335
+ let d = CGSize(width: a.pixelWidth, height: a.pixelHeight)
1336
+ let size = VideoExporter.predictedOutputSize(displaySize: d, state: state, options: videoOptions, sourceDuration: a.duration)
1337
+ var item: [String: Any] = [
1338
+ "index": index, "mediaType": "video", "originalUri": "ph://\(a.localIdentifier)",
1339
+ "width": Int(size.width), "height": Int(size.height),
1340
+ "duration": state.outputDuration(sourceDuration: a.duration),
1341
+ ]
1342
+ if let poster = image {
1343
+ let edited = state.applyEdits(to: poster)
1344
+ if let encoded = VideoExporter.encodePoster(edited, format: videoOptions.posterFormat, quality: 0.8) {
1345
+ let path = (tempDir as NSString).appendingPathComponent("turbo_video_poster_\(Int(Date().timeIntervalSince1970 * 1000))_\(index).\(encoded.fileExtension)")
1346
+ if (try? encoded.data.write(to: URL(fileURLWithPath: path))) != nil {
1347
+ item["thumbnailUri"] = "file://" + path
1348
+ item["thumbnailWidth"] = Int(edited.size.width * edited.scale)
1349
+ item["thumbnailHeight"] = Int(edited.size.height * edited.scale)
1350
+ }
1351
+ }
1352
+ }
1353
+ return item
1354
+ }
1355
+ // 사진: 리사이즈 규칙(resizeImage)과 같은 예상 크기 (resizeImage 처럼 image.size 기준)
1356
+ var w = image?.size.width ?? CGFloat(asset?.pixelWidth ?? 0)
1357
+ var h = image?.size.height ?? CGFloat(asset?.pixelHeight ?? 0)
1358
+ if w > 0, h > 0 {
1359
+ var ratio: CGFloat = 1
1360
+ if maxWidth > 0 && maxHeight > 0 { ratio = min(CGFloat(maxWidth) / w, CGFloat(maxHeight) / h, 1) }
1361
+ else if maxWidth > 0 { ratio = min(CGFloat(maxWidth) / w, 1) }
1362
+ else if maxHeight > 0 { ratio = min(CGFloat(maxHeight) / h, 1) }
1363
+ w *= ratio; h *= ratio
1364
+ }
1365
+ var item: [String: Any] = ["index": index, "mediaType": "image", "width": Int(w.rounded()), "height": Int(h.rounded())]
1366
+ if let a = asset { item["originalUri"] = "ph://\(a.localIdentifier)" }
1367
+ return item
1368
+ }
1369
+
1370
+ /// 동영상 포스터 (긴 변 512, 동기) — 처리 시작 이벤트용
1371
+ private static func requestPoster(_ asset: PHAsset) -> UIImage? {
1372
+ let o = PHImageRequestOptions()
1373
+ o.isSynchronous = true
1374
+ o.deliveryMode = .highQualityFormat
1375
+ o.resizeMode = .fast
1376
+ o.isNetworkAccessAllowed = true
1377
+ var out: UIImage?
1378
+ PHImageManager.default().requestImage(for: asset, targetSize: CGSize(width: 512, height: 512), contentMode: .aspectFit, options: o) { img, _ in
1379
+ out = img
1380
+ }
1381
+ return out
1382
+ }
1383
+
1153
1384
  @objc
1154
1385
  func closeGallery(_ resolve: @escaping RCTPromiseResolveBlock,
1155
1386
  reject: @escaping RCTPromiseRejectBlock) -> Void {
@@ -1457,9 +1688,105 @@ class RNAttachmentKit: RCTEventEmitter {
1457
1688
  }
1458
1689
 
1459
1690
  // MARK: - Editor Result Processing
1691
+
1692
+ /// 동영상 1장을 전송용 mp4 로 만들어 resolve (갤러리 다중 전송 경로의 동영상 분기와 같은 필드)
1693
+ private func exportSingleVideo(_ phAsset: PHAsset, galleryVC: GalleryViewController?, tempDir: String,
1694
+ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
1695
+ let videoOptions = galleryVC?.videoExportOptions ?? VideoExportOptions()
1696
+ let state = galleryVC?.videoEditStates[phAsset.localIdentifier] ?? VideoEditState.defaultState(for: phAsset, options: videoOptions)
1697
+ 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
1698
+ let exporter = VideoExporter()
1699
+ let hudCancelled = AtomicFlag()
1700
+ hud?.onCancel = { hudCancelled.value = true; exporter.cancel() }
1701
+ let job = MediaProcessingJob()
1702
+ let processingId = UUID().uuidString
1703
+ processingJobs[processingId] = job
1704
+ lastProcessingId = processingId
1705
+ DispatchQueue.global(qos: .userInitiated).async {
1706
+ let poster = Self.requestPoster(phAsset)
1707
+ self.sendEvent(withName: "onProcessingStart", body: [
1708
+ "processingId": processingId,
1709
+ "total": 1,
1710
+ "items": [self.processingStartItem(index: 0, asset: phAsset, image: poster, videoStates: [phAsset.localIdentifier: state],
1711
+ videoOptions: videoOptions, maxWidth: 0, maxHeight: 0, tempDir: tempDir)],
1712
+ ])
1713
+ let timestamp = Int(Date().timeIntervalSince1970 * 1000)
1714
+ var result: [String: Any] = [
1715
+ "originalUri": "ph://\(phAsset.localIdentifier)",
1716
+ "originalWidth": phAsset.pixelWidth,
1717
+ "originalHeight": phAsset.pixelHeight,
1718
+ "mediaType": "video",
1719
+ "type": "video/mp4",
1720
+ "duration": phAsset.duration,
1721
+ ]
1722
+ do {
1723
+ job.begin(index: 0, exporter: exporter)
1724
+ let throttle = ProgressThrottle()
1725
+ let onProgress: (Double) -> Void = { p in
1726
+ guard throttle.shouldSend(p) else { return }
1727
+ DispatchQueue.main.async { hud?.update(progress: p, index: 0, total: 1) }
1728
+ self.sendEvent(withName: "onVideoProgress", body: ["index": 0, "total": 1, "progress": p, "processingId": processingId])
1729
+ }
1730
+ let r: VideoExportResult
1731
+ // HUD 취소는 exporter.cancel() — 기다리는 동안엔 job 으로 알 수 없으므로 HUD 취소도 job 에 기록
1732
+ if let ready = VideoPrepareManager.shared.takeReady(asset: phAsset, state: state, progress: onProgress,
1733
+ isCancelled: { job.isCancelled(0) || hudCancelled.value }) {
1734
+ r = ready; onProgress(1)
1735
+ } else {
1736
+ if job.isCancelled(0) || hudCancelled.value { throw VideoExporter.ExportError.cancelled }
1737
+ r = try exporter.export(asset: phAsset, state: state, options: videoOptions,
1738
+ outputDir: URL(fileURLWithPath: tempDir), baseName: "turbo_video_\(timestamp)_0", progress: onProgress)
1739
+ }
1740
+ result["uri"] = "file://" + r.url.path
1741
+ result["width"] = r.width
1742
+ result["height"] = r.height
1743
+ result["duration"] = r.duration
1744
+ result["fileSize"] = r.fileSize
1745
+ result["fileName"] = r.url.lastPathComponent
1746
+ result["fileExtension"] = "mp4"
1747
+ result["videoCodec"] = r.codec
1748
+ result["videoBitrate"] = r.bitrate
1749
+ result["edited"] = !r.passthrough
1750
+ if let t = r.thumbnailURL {
1751
+ result["thumbnailUri"] = "file://" + t.path
1752
+ result["thumbnailWidth"] = r.thumbnailWidth
1753
+ result["thumbnailHeight"] = r.thumbnailHeight
1754
+ }
1755
+ if job.isCancelled(0) { throw VideoExporter.ExportError.cancelled }
1756
+ } catch VideoExporter.ExportError.cancelled where job.isCancelled(0) {
1757
+ // 앱이 취소 (cancelVideoProcessing) — 이벤트·결과에 cancelled 로
1758
+ result["error"] = "CANCELLED"
1759
+ result["cancelled"] = true
1760
+ } catch VideoExporter.ExportError.cancelled {
1761
+ job.end()
1762
+ DispatchQueue.main.async {
1763
+ self.processingJobs[processingId] = nil
1764
+ hud?.dismiss(); reject("CANCELLED", "Video export cancelled", nil)
1765
+ }
1766
+ return
1767
+ } catch {
1768
+ result["error"] = error.localizedDescription
1769
+ }
1770
+ job.end()
1771
+ self.sendEvent(withName: "onImageProcessed", body: ["index": 0, "total": 1, "image": result, "processingId": processingId])
1772
+ DispatchQueue.main.async {
1773
+ hud?.dismiss()
1774
+ if self.currentGalleryVC == nil || self.currentGalleryVC === galleryVC { self.currentGalleryVC = nil }
1775
+ self.processingJobs[processingId] = nil
1776
+ galleryVC?.releaseAfterReturn(discardPreparedVideos: self.currentGalleryVC == nil)
1777
+ resolve([result])
1778
+ }
1779
+ }
1780
+ }
1781
+
1460
1782
 
1461
1783
  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
1784
  let tempDir = prepareCacheDirectory()
1785
+ // 동영상 1장 편집 → 전송: 편집 화면에서 고른 품질·음소거로 VideoExporter (사진 경로와 같은 결과 형태)
1786
+ if let phAsset = asset, phAsset.mediaType == .video {
1787
+ exportSingleVideo(phAsset, galleryVC: galleryVC, tempDir: tempDir, resolve: resolve, reject: reject)
1788
+ return
1789
+ }
1463
1790
  let maxWidth = targetMaxWidth ?? galleryVC?.maxWidth ?? 1024
1464
1791
  let maxHeight = targetMaxHeight ?? galleryVC?.maxHeight ?? 1024
1465
1792
  let format = galleryVC?.outputFormat ?? "webp"
@@ -1906,6 +2233,7 @@ fileprivate extension RNAttachmentKit {
1906
2233
 
1907
2234
  editorVC.onEditDeleted = { [weak currentGallery] asset in
1908
2235
  currentGallery?.editedImages.removeValue(forKey: asset.localIdentifier)
2236
+ currentGallery?.videoEditStates.removeValue(forKey: asset.localIdentifier) // 동영상 편집도 기본으로 돌아간 것
1909
2237
  }
1910
2238
 
1911
2239
  editorVC.modalPresentationStyle = .overFullScreen
@@ -1921,7 +2249,22 @@ fileprivate extension RNAttachmentKit {
1921
2249
  }
1922
2250
  }
1923
2251
  editorVC.croppedImages = initialCroppedImages
2252
+ // 동영상 페이지: 품질 단계·용량 상한 + 갤러리가 들고 있는 상태. 바뀌면 바로 갤러리에 반영 (전송 때 VideoExporter 가 읽음)
2253
+ editorVC.videoOptions = VideoExportOptions(dictionary: options["video"] as? [String: Any])
2254
+ var initialVideoStates: [Int: VideoEditState] = [:]
2255
+ for (i, a) in editorAssets.enumerated() {
2256
+ if let st = currentGallery.videoEditStates[a.localIdentifier] { initialVideoStates[i] = st }
2257
+ }
2258
+ editorVC.videoEditStates = initialVideoStates
2259
+ editorVC.onVideoStateChanged = { [weak currentGallery] asset, state in
2260
+ currentGallery?.videoEditStates[asset.localIdentifier] = state
2261
+ }
1924
2262
 
2263
+ if sourceFrame == .zero {
2264
+ // 기준 칸이 없을 때(칸이 화면 밖 등): 기본 전환(아래로 미끄러짐)은 끌어내려 닫을 때 위로 치워 둔 상단 바가
2265
+ // 화면과 함께 내려오며 보이므로 페이드로
2266
+ editorVC.modalTransitionStyle = .crossDissolve
2267
+ }
1925
2268
  if sourceFrame != .zero {
1926
2269
  self.editorTransitionDelegate = ImageEditorTransitionDelegate()
1927
2270
  self.editorTransitionDelegate?.sourceFrame = sourceFrame
@@ -1961,7 +2304,10 @@ fileprivate extension RNAttachmentKit {
1961
2304
  // (호출 중인 객체이므로 다음 런루프에서)
1962
2305
  DispatchQueue.main.async { self?.editorTransitionDelegate = nil }
1963
2306
  }
1964
- currentGallery.setHiddenAsset(asset)
2307
+ // 칸은 열기 애니메이션의 첫 프레임에 비움 (누르자마자 비우면 편집 화면이 준비되는 동안 빈 칸이 번쩍임)
2308
+ self.editorTransitionDelegate?.onPresentationStarted = { [weak currentGallery] in
2309
+ currentGallery?.setHiddenAsset(asset)
2310
+ }
1965
2311
 
1966
2312
  editorVC.transitioningDelegate = self.editorTransitionDelegate
1967
2313
  }
@@ -2085,3 +2431,69 @@ extension RNAttachmentKit: UIViewControllerTransitioningDelegate {
2085
2431
  return zoomAnimator
2086
2432
  }
2087
2433
  }
2434
+
2435
+ // MARK: - 전송 처리 상태 (앱에서 동영상 취소)
2436
+
2437
+ /// openGallery 결과 처리 한 번. cancelVideoProcessing(index) → 그 동영상만(또는 전부) 취소
2438
+ private final class MediaProcessingJob {
2439
+ private let lock = NSLock()
2440
+ private var cancelAll = false
2441
+ private var cancelled = Set<Int>()
2442
+ private var currentIndex: Int?
2443
+ private var currentExporter: VideoExporter?
2444
+
2445
+ func cancel(index: Int?) {
2446
+ lock.lock(); defer { lock.unlock() }
2447
+ if let i = index {
2448
+ cancelled.insert(i)
2449
+ if currentIndex == i { currentExporter?.cancel() }
2450
+ } else {
2451
+ cancelAll = true
2452
+ currentExporter?.cancel()
2453
+ }
2454
+ }
2455
+
2456
+ func isCancelled(_ i: Int) -> Bool {
2457
+ lock.lock(); defer { lock.unlock() }
2458
+ return cancelAll || cancelled.contains(i)
2459
+ }
2460
+
2461
+ func begin(index: Int, exporter: VideoExporter) {
2462
+ lock.lock(); defer { lock.unlock() }
2463
+ currentIndex = index
2464
+ currentExporter = exporter
2465
+ }
2466
+
2467
+ func end() {
2468
+ lock.lock(); defer { lock.unlock() }
2469
+ currentIndex = nil
2470
+ currentExporter = nil
2471
+ }
2472
+ }
2473
+
2474
+ /// onVideoProgress 를 1% 이상 바뀌고 0.1초 지났을 때만 (브릿지가 넘치지 않게). 0 과 1 은 항상
2475
+ private final class ProgressThrottle {
2476
+ private let lock = NSLock()
2477
+ private var last: Double = -1
2478
+ private var lastTime: CFTimeInterval = 0
2479
+
2480
+ func shouldSend(_ p: Double) -> Bool {
2481
+ lock.lock(); defer { lock.unlock() }
2482
+ let now = CACurrentMediaTime()
2483
+ guard p >= 1 || last < 0 || (p - last >= 0.01 && now - lastTime >= 0.1) else { return false }
2484
+ if p >= 1 && last >= 1 { return false }
2485
+ last = p
2486
+ lastTime = now
2487
+ return true
2488
+ }
2489
+ }
2490
+
2491
+ /// 스레드 사이에서 읽고 쓰는 Bool
2492
+ private final class AtomicFlag {
2493
+ private let lock = NSLock()
2494
+ private var v = false
2495
+ var value: Bool {
2496
+ get { lock.lock(); defer { lock.unlock() }; return v }
2497
+ set { lock.lock(); v = newValue; lock.unlock() }
2498
+ }
2499
+ }
@@ -8,32 +8,32 @@
8
8
  <key>BinaryPath</key>
9
9
  <string>RNAttachmentKit.framework/RNAttachmentKit</string>
10
10
  <key>LibraryIdentifier</key>
11
- <string>ios-arm64_x86_64-simulator</string>
11
+ <string>ios-arm64</string>
12
12
  <key>LibraryPath</key>
13
13
  <string>RNAttachmentKit.framework</string>
14
14
  <key>SupportedArchitectures</key>
15
15
  <array>
16
16
  <string>arm64</string>
17
- <string>x86_64</string>
18
17
  </array>
19
18
  <key>SupportedPlatform</key>
20
19
  <string>ios</string>
21
- <key>SupportedPlatformVariant</key>
22
- <string>simulator</string>
23
20
  </dict>
24
21
  <dict>
25
22
  <key>BinaryPath</key>
26
23
  <string>RNAttachmentKit.framework/RNAttachmentKit</string>
27
24
  <key>LibraryIdentifier</key>
28
- <string>ios-arm64</string>
25
+ <string>ios-arm64_x86_64-simulator</string>
29
26
  <key>LibraryPath</key>
30
27
  <string>RNAttachmentKit.framework</string>
31
28
  <key>SupportedArchitectures</key>
32
29
  <array>
33
30
  <string>arm64</string>
31
+ <string>x86_64</string>
34
32
  </array>
35
33
  <key>SupportedPlatform</key>
36
34
  <string>ios</string>
35
+ <key>SupportedPlatformVariant</key>
36
+ <string>simulator</string>
37
37
  </dict>
38
38
  </array>
39
39
  <key>CFBundlePackageType</key>
@@ -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>