react-native-attachment-kit 1.0.8 → 1.0.10

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 (32) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +40 -2
  3. package/android/CMakeLists.txt +3 -0
  4. package/android/build.gradle +11 -2
  5. package/android/src/main/libs/rnattachmentkit-release.aar +0 -0
  6. package/android/src/newarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +48 -2
  7. package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +48 -2
  8. package/ios/RNAttachmentKit.swift +166 -15
  9. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Assets.car +0 -0
  10. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.abi.json +738 -426
  11. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +12 -1
  12. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  13. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftinterface +12 -1
  14. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  15. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  16. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeResources +12 -12
  17. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeSignature +0 -0
  18. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Assets.car +0 -0
  19. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +738 -426
  20. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +12 -1
  21. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  22. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftinterface +12 -1
  23. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +738 -426
  24. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +12 -1
  25. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  26. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +12 -1
  27. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  28. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  29. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeResources +22 -22
  30. package/package.json +1 -1
  31. package/src/index.ts +1 -1
  32. package/src/types.ts +34 -1
@@ -57,6 +57,8 @@ class RNAttachmentKit: RCTEventEmitter {
57
57
  /// 동영상 변환 진행 HUD 를 띄울지 — video.showProgressHUD, 없으면 asyncProcessing 이 아닐 때만
58
58
  /// (앱이 onProcessingStart / onVideoProgress / onImageProcessed 로 말풍선에 진행 상황을 그리는 구조면 HUD 없이)
59
59
  private var galleryShowsVideoHUD = true
60
+ /// 사진 썸네일 (GalleryOptions.thumbnail). nil 이면 만들지 않음 (기존과 같음)
61
+ private var galleryThumbnail: PhotoThumbnailOptions?
60
62
 
61
63
  // MARK: - Module Setup
62
64
 
@@ -240,6 +242,9 @@ class RNAttachmentKit: RCTEventEmitter {
240
242
  func openMapViewer(_ options: NSDictionary,
241
243
  resolve: @escaping RCTPromiseResolveBlock,
242
244
  reject: @escaping RCTPromiseRejectBlock) -> Void {
245
+ guard LicenseGate.isLicensed else {
246
+ reject(LicenseGate.errorCode, LicenseGate.errorMessage, nil); return
247
+ }
243
248
  guard let lat = RNAttachmentKit.cgFloat(options["latitude"]),
244
249
  let lng = RNAttachmentKit.cgFloat(options["longitude"]) else {
245
250
  reject("ERROR", "latitude and longitude are required", nil)
@@ -308,6 +313,9 @@ class RNAttachmentKit: RCTEventEmitter {
308
313
  func renderLocationThumbnail(_ options: NSDictionary,
309
314
  resolve: @escaping RCTPromiseResolveBlock,
310
315
  reject: @escaping RCTPromiseRejectBlock) -> Void {
316
+ guard LicenseGate.isLicensed else {
317
+ reject(LicenseGate.errorCode, LicenseGate.errorMessage, nil); return
318
+ }
311
319
  guard let lat = RNAttachmentKit.cgFloat(options["latitude"]),
312
320
  let lng = RNAttachmentKit.cgFloat(options["longitude"]),
313
321
  let w = RNAttachmentKit.cgFloat(options["width"]), w > 0 else {
@@ -366,6 +374,9 @@ class RNAttachmentKit: RCTEventEmitter {
366
374
  func reverseGeocode(_ options: NSDictionary,
367
375
  resolve: @escaping RCTPromiseResolveBlock,
368
376
  reject: @escaping RCTPromiseRejectBlock) -> Void {
377
+ guard LicenseGate.isLicensed else {
378
+ reject(LicenseGate.errorCode, LicenseGate.errorMessage, nil); return
379
+ }
369
380
  let lat = (options["latitude"] as? NSNumber)?.doubleValue ?? .nan
370
381
  let lng = (options["longitude"] as? NSNumber)?.doubleValue ?? .nan
371
382
  let lang = (options["languageCode"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "en"
@@ -383,6 +394,10 @@ class RNAttachmentKit: RCTEventEmitter {
383
394
  resolve: @escaping RCTPromiseResolveBlock,
384
395
  reject: @escaping RCTPromiseRejectBlock) -> Void {
385
396
  let dict = config as? [String: Any] ?? [:]
397
+ // 라이선스 키가 없어도 설정은 받아 둠 (AI 버튼은 보이고, 누르면 "라이선스 필요" 안내)
398
+ if !LicenseGate.isLicensed, (dict["enabled"] as? Bool) == true {
399
+ NSLog("[RNAttachmentKit] AI needs a license key — the AI buttons show a license notice until init() gets a valid key")
400
+ }
386
401
  DispatchQueue.main.async {
387
402
  AIBilling.apply(dict)
388
403
  AIBilling.onUsage = { [weak self] body in self?.sendEvent(withName: "onAIUsage", body: body) }
@@ -421,6 +436,10 @@ class RNAttachmentKit: RCTEventEmitter {
421
436
  let cacheFiles: [String]? = (options["cacheFiles"] as? [Any])?.map { ($0 as? String) ?? "" }
422
437
  // videos: images 와 같은 순서. 항목이 있으면 그 페이지는 동영상 (images[i] 는 포스터)
423
438
  let viewerVideos: [ViewerVideoItem?] = (options["videos"] as? [Any])?.map { ViewerVideoItem(dictionary: $0) } ?? []
439
+ // 동영상 페이지는 라이선스 키가 있어야
440
+ if !LicenseGate.isLicensed, viewerVideos.contains(where: { $0 != nil }) {
441
+ reject(LicenseGate.errorCode, LicenseGate.errorMessage, nil); return
442
+ }
424
443
  let themeColorHex = options["themeColor"] as? String
425
444
  let languageCode = options["languageCode"] as? String ?? "en"
426
445
  let viewerTitle = options["title"] as? String
@@ -679,9 +698,10 @@ class RNAttachmentKit: RCTEventEmitter {
679
698
  var parsedMaxHeight: Int? = nil
680
699
  if let maxW = options["maxWidth"] as? Int { parsedMaxWidth = maxW }
681
700
  if let maxH = options["maxHeight"] as? Int { parsedMaxHeight = maxH }
701
+ let parsedThumbnail = PhotoThumbnailOptions(options["thumbnail"])
682
702
 
683
703
  DispatchQueue.main.async {
684
- guard let rootViewController = self.getRootViewController() else {
704
+ guard self.getRootViewController() != nil else {
685
705
  reject("ERROR", "Unable to get root view controller", nil)
686
706
  return
687
707
  }
@@ -716,7 +736,7 @@ class RNAttachmentKit: RCTEventEmitter {
716
736
 
717
737
  editorVC.onConfirm = { [weak self, weak editorVC] (editedAsset, editedImage, filterState, caption) in
718
738
  editorVC?.dismiss(animated: true) {
719
- self?.processEditorResult(asset: editedAsset, croppedImage: editedImage, filterState: filterState, caption: caption, galleryVC: nil, targetMaxWidth: parsedMaxWidth, targetMaxHeight: parsedMaxHeight, resolve: { resultArr in
739
+ self?.processEditorResult(asset: editedAsset, croppedImage: editedImage, filterState: filterState, caption: caption, galleryVC: nil, targetMaxWidth: parsedMaxWidth, targetMaxHeight: parsedMaxHeight, thumbnail: parsedThumbnail, resolve: { resultArr in
720
740
  if let arr = resultArr as? [[String: Any]], let first = arr.first {
721
741
  resolve(first)
722
742
  } else {
@@ -732,11 +752,7 @@ class RNAttachmentKit: RCTEventEmitter {
732
752
  }
733
753
  }
734
754
 
735
- var topController = rootViewController
736
- while let presented = topController.presentedViewController {
737
- topController = presented
738
- }
739
- topController.present(editorVC, animated: true)
755
+ self.presentOnTop(editorVC, reject: reject)
740
756
 
741
757
  } else {
742
758
  guard let url = URL(string: uri) ?? URL(string: "file://" + uri.replacingOccurrences(of: "file://", with: "")) else {
@@ -760,7 +776,7 @@ class RNAttachmentKit: RCTEventEmitter {
760
776
 
761
777
  editorVC.onConfirm = { [weak self, weak editorVC] (editedAsset, editedImage, filterState, caption) in
762
778
  editorVC?.dismiss(animated: true) {
763
- self?.processEditorResult(asset: editedAsset, croppedImage: editedImage, filterState: filterState, caption: caption, galleryVC: nil, targetMaxWidth: parsedMaxWidth, targetMaxHeight: parsedMaxHeight, resolve: { resultArr in
779
+ self?.processEditorResult(asset: editedAsset, croppedImage: editedImage, filterState: filterState, caption: caption, galleryVC: nil, targetMaxWidth: parsedMaxWidth, targetMaxHeight: parsedMaxHeight, thumbnail: parsedThumbnail, resolve: { resultArr in
764
780
  var resultDict = (resultArr as? [[String: Any]])?.first ?? (resultArr as? [String: Any]) ?? [:]
765
781
  // For remote URLs, we preserve originalUri if editedFileUri isn't provided
766
782
  let originalRemoteUrl = (options["editedFileUri"] as? String) ?? uri
@@ -776,11 +792,7 @@ class RNAttachmentKit: RCTEventEmitter {
776
792
  }
777
793
  }
778
794
 
779
- var topController = rootViewController
780
- while let presented = topController.presentedViewController {
781
- topController = presented
782
- }
783
- topController.present(editorVC, animated: true)
795
+ self.presentOnTop(editorVC, reject: reject)
784
796
  }
785
797
  } else {
786
798
  DispatchQueue.main.async {
@@ -868,6 +880,8 @@ class RNAttachmentKit: RCTEventEmitter {
868
880
  // maxWidth, maxHeight 옵션 (미설정 또는 0이면 원본 크기 유지)
869
881
  galleryVC.maxWidth = (options["maxWidth"] as? Int) ?? 0
870
882
  galleryVC.maxHeight = (options["maxHeight"] as? Int) ?? 0
883
+ // 사진 썸네일 (동영상 포스터 규칙은 그대로 — video.posterFormat, 긴 변 512)
884
+ self.galleryThumbnail = PhotoThumbnailOptions(options["thumbnail"])
871
885
 
872
886
  // outputFormat: "webp" | "jpg" | "png" (기본값: "webp")
873
887
  let fmt = (options["outputFormat"] as? String)?.lowercased() ?? "webp"
@@ -973,6 +987,7 @@ class RNAttachmentKit: RCTEventEmitter {
973
987
  // 동영상: mediaType "photo"(기본) | "video" | "all" + video 옵션 (지시서 3-1)
974
988
  let mediaTypeOption = (options["mediaType"] as? String)?.lowercased() ?? "photo"
975
989
  galleryVC.mediaType = ["photo", "video", "all"].contains(mediaTypeOption) ? mediaTypeOption : "photo"
990
+ // 동영상은 라이선스 키가 있어야 — 없으면 갤러리가 사진만 보여 줌 (GalleryBaseViewController.viewDidLoad)
976
991
  let videoOptions = VideoExportOptions(dictionary: options["video"] as? [String: Any])
977
992
  let asyncProcessing = options["asyncProcessing"] as? Bool ?? false
978
993
  self.galleryShowsVideoHUD = ((options["video"] as? [String: Any])?["showProgressHUD"] as? Bool) ?? !asyncProcessing
@@ -1004,6 +1019,7 @@ class RNAttachmentKit: RCTEventEmitter {
1004
1019
  let galleryFormat = galleryVC.outputFormat // "webp" | "jpg" | "png"
1005
1020
  let galleryAccent = galleryVC.parsedThemeColor
1006
1021
  let galleryLanguage = galleryVC.languageCode
1022
+ let galleryThumb = self.galleryThumbnail
1007
1023
  galleryVC.onImagesSelected = { [weak self, weak galleryVC] selectedImages in
1008
1024
  guard let self = self else { resolve([]); return }
1009
1025
 
@@ -1176,6 +1192,9 @@ class RNAttachmentKit: RCTEventEmitter {
1176
1192
  result["fileExtension"] = meta.fileExtension
1177
1193
  result["fileSize"] = meta.fileSize
1178
1194
  }
1195
+ // 썸네일: 같은 비트맵(방향·크롭·그리기 반영)에서
1196
+ result.merge(self.makePhotoThumbnail(targetImage, options: galleryThumb, index: index,
1197
+ timestamp: timestamp, tempDir: tempDir)) { $1 }
1179
1198
 
1180
1199
  resultsLock.lock()
1181
1200
  results.append(result)
@@ -1227,6 +1246,9 @@ class RNAttachmentKit: RCTEventEmitter {
1227
1246
  result["fileExtension"] = meta.fileExtension
1228
1247
  result["fileSize"] = meta.fileSize
1229
1248
  }
1249
+ // 썸네일: 같은 비트맵(방향·크롭·그리기 반영)에서
1250
+ result.merge(self.makePhotoThumbnail(targetImage, options: galleryThumb, index: index,
1251
+ timestamp: timestamp, tempDir: tempDir)) { $1 }
1230
1252
 
1231
1253
  resultsLock.lock()
1232
1254
  results.append(result)
@@ -1621,6 +1643,66 @@ class RNAttachmentKit: RCTEventEmitter {
1621
1643
  }
1622
1644
  }
1623
1645
 
1646
+ // MARK: - Photo thumbnail (GalleryOptions.thumbnail / EditorOptions.thumbnail)
1647
+
1648
+ /// 결과 크기와 원본에서 잘라 쓸 영역 (픽셀). Android PhotoThumbnail.layout 과 같은 계산
1649
+ /// - fit: ratio = min(maxW/w, maxH/h, 1) → round(w·ratio) × round(h·ratio), 자르지 않음
1650
+ /// - cover: 상자 비율로 가운데를 자른 뒤 maxW×maxH 로 줄임. 원본이 상자보다 작으면 확대하지 않고 자른 크기 그대로
1651
+ static func photoThumbnailLayout(width w: Int, height h: Int, options o: PhotoThumbnailOptions) -> (crop: CGRect, size: CGSize) {
1652
+ let dw = Double(w), dh = Double(h)
1653
+ if !o.cover {
1654
+ let r = min(Double(o.maxWidth) / dw, Double(o.maxHeight) / dh, 1)
1655
+ return (CGRect(x: 0, y: 0, width: w, height: h),
1656
+ CGSize(width: max(1, (dw * r).rounded()), height: max(1, (dh * r).rounded())))
1657
+ }
1658
+ let aspect = Double(o.maxWidth) / Double(o.maxHeight)
1659
+ let cropW: Double, cropH: Double
1660
+ if dw / dh > aspect { cropH = dh; cropW = dh * aspect } else { cropW = dw; cropH = dw / aspect }
1661
+ let left = ((dw - cropW) / 2).rounded(), top = ((dh - cropH) / 2).rounded()
1662
+ let crop = CGRect(x: left, y: top, width: min(dw - left, cropW.rounded()), height: min(dh - top, cropH.rounded()))
1663
+ let size = cropW > Double(o.maxWidth)
1664
+ ? CGSize(width: o.maxWidth, height: o.maxHeight)
1665
+ : CGSize(width: max(1, cropW.rounded()), height: max(1, cropH.rounded()))
1666
+ return (crop, size)
1667
+ }
1668
+
1669
+ /// 사진 썸네일을 만들어 결과에 넣을 필드(thumbnailUri/Width/Height/FileSize)를 돌려줌. options 가 없거나 실패하면 빈 딕셔너리.
1670
+ /// image 는 결과 사진(uri)을 만든 것과 같은 UIImage — 원본을 다시 읽지 않음. 새로 그려 인코딩하므로 EXIF·GPS 는 들어가지 않음
1671
+ private func makePhotoThumbnail(_ image: UIImage, options: PhotoThumbnailOptions?, index: Int,
1672
+ timestamp: Int, tempDir: String) -> [String: Any] {
1673
+ guard let o = options else { return [:] }
1674
+ // 방향(imageOrientation) 반영된 픽셀 크기
1675
+ let w = Int((image.size.width * image.scale).rounded()), h = Int((image.size.height * image.scale).rounded())
1676
+ guard w > 0, h > 0 else { return [:] }
1677
+ let (crop, size) = Self.photoThumbnailLayout(width: w, height: h, options: o)
1678
+ let format = UIGraphicsImageRendererFormat()
1679
+ format.scale = 1
1680
+ format.opaque = false
1681
+ format.preferredRange = .standard
1682
+ let sx = size.width / crop.width, sy = size.height / crop.height
1683
+ let thumb = UIGraphicsImageRenderer(size: size, format: format).image { ctx in
1684
+ ctx.cgContext.interpolationQuality = .high
1685
+ image.draw(in: CGRect(x: -crop.minX * sx, y: -crop.minY * sy, width: CGFloat(w) * sx, height: CGFloat(h) * sy))
1686
+ }
1687
+ // 색 프로필(sRGB ICC, 약 3KB)을 붙이지 않도록 장치 RGB 로 태그 — 수 KB 짜리 썸네일에선 용량의 절반 가까이라.
1688
+ // 프로필이 없는 이미지는 sRGB 로 읽히므로 색은 같음
1689
+ let untagged = thumb.cgImage.flatMap { $0.copy(colorSpace: CGColorSpaceCreateDeviceRGB()) }.map { UIImage(cgImage: $0) } ?? thumb
1690
+ // webp 인코딩이 안 되면 jpg 로
1691
+ var data: Data? = nil
1692
+ var ext = "jpg"
1693
+ if o.format == "webp", let d = encodeWebP(image: untagged, quality: o.quality) { data = d; ext = "webp" }
1694
+ if data == nil { data = untagged.jpegData(compressionQuality: o.quality); ext = "jpg" }
1695
+ guard let bytes = data else { return [:] }
1696
+ let url = URL(fileURLWithPath: (tempDir as NSString).appendingPathComponent("turbo_picker_thumb_\(timestamp)_\(index).\(ext)"))
1697
+ do { try bytes.write(to: url, options: [.atomic]) } catch { return [:] }
1698
+ return [
1699
+ "thumbnailUri": url.absoluteString,
1700
+ "thumbnailWidth": Int(size.width),
1701
+ "thumbnailHeight": Int(size.height),
1702
+ "thumbnailFileSize": bytes.count,
1703
+ ]
1704
+ }
1705
+
1624
1706
  /// 이미지를 지정 포맷으로 저장하고 file:// URI 반환
1625
1707
  /// - Parameters:
1626
1708
  /// - image: 저장할 UIImage
@@ -1686,6 +1768,36 @@ class RNAttachmentKit: RCTEventEmitter {
1686
1768
  return (fileName, fileExtension, fileSize)
1687
1769
  }
1688
1770
 
1771
+ /// 맨 위 컨트롤러에 present. 맨 위가 닫히거나 열리는 중이면(예: openGallery 가 끝난 직후 갤러리 시트가
1772
+ /// 닫히는 애니메이션) UIKit 이 present 를 조용히 버리므로, 전환이 끝난 뒤 다시 시도합니다.
1773
+ /// 끝내 present 되지 않으면 reject — 안 그러면 JS 약속이 안 풀리고 _editorOpen 가드에 계속 막힙니다.
1774
+ private func presentOnTop(_ vc: UIViewController, reject: @escaping RCTPromiseRejectBlock, attempt: Int = 0) {
1775
+ guard let top = getRootViewController() else {
1776
+ reject("E_NO_ROOT", "Unable to get root view controller", nil)
1777
+ return
1778
+ }
1779
+ if (top.isBeingDismissed || top.isBeingPresented) && attempt < 20 {
1780
+ let retry: () -> Void = { [weak self] in
1781
+ guard let self = self else {
1782
+ reject("E_PRESENT_FAILED", "Module deallocated", nil)
1783
+ return
1784
+ }
1785
+ self.presentOnTop(vc, reject: reject, attempt: attempt + 1)
1786
+ }
1787
+ if let coordinator = top.transitionCoordinator {
1788
+ coordinator.animate(alongsideTransition: nil) { _ in DispatchQueue.main.async(execute: retry) }
1789
+ } else {
1790
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: retry)
1791
+ }
1792
+ return
1793
+ }
1794
+ top.present(vc, animated: true)
1795
+ // present 가 받아들여지면 presentingViewController 가 바로 잡힘. nil 이면 UIKit 이 거부한 것
1796
+ if vc.presentingViewController == nil {
1797
+ reject("E_PRESENT_FAILED", "Unable to present \(type(of: vc)) on \(type(of: top))", nil)
1798
+ }
1799
+ }
1800
+
1689
1801
  private func getRootViewController() -> UIViewController? {
1690
1802
  var rootViewController: UIViewController?
1691
1803
 
@@ -1806,7 +1918,7 @@ class RNAttachmentKit: RCTEventEmitter {
1806
1918
  }
1807
1919
 
1808
1920
 
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) {
1921
+ private func processEditorResult(asset: PHAsset?, croppedImage: UIImage?, filterState: FilterState, caption: String, galleryVC: GalleryViewController?, targetMaxWidth: Int? = nil, targetMaxHeight: Int? = nil, thumbnail: PhotoThumbnailOptions? = nil, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
1810
1922
  let tempDir = prepareCacheDirectory()
1811
1923
  // 동영상 1장 편집 → 전송: 편집 화면에서 고른 품질·음소거로 VideoExporter (사진 경로와 같은 결과 형태)
1812
1924
  if let phAsset = asset, phAsset.mediaType == .video {
@@ -1816,6 +1928,8 @@ class RNAttachmentKit: RCTEventEmitter {
1816
1928
  let maxWidth = targetMaxWidth ?? galleryVC?.maxWidth ?? 1024
1817
1929
  let maxHeight = targetMaxHeight ?? galleryVC?.maxHeight ?? 1024
1818
1930
  let format = galleryVC?.outputFormat ?? "webp"
1931
+ // openEditor 는 자기 옵션, 갤러리 편집 경로는 openGallery 의 thumbnail
1932
+ let thumbOptions = thumbnail ?? (galleryVC != nil ? galleryThumbnail : nil)
1819
1933
 
1820
1934
  DispatchQueue.global(qos: .userInitiated).async {
1821
1935
  let photoManager = PhotoManager.shared
@@ -1901,6 +2015,9 @@ class RNAttachmentKit: RCTEventEmitter {
1901
2015
  result["fileExtension"] = finalMeta.fileExtension
1902
2016
  result["fileSize"] = finalMeta.fileSize
1903
2017
  }
2018
+ // 썸네일: 크롭·그리기·필터가 반영된 결과(uri 와 같은 비트맵)에서
2019
+ result.merge(self.makePhotoThumbnail(finalImageForFormat, options: thumbOptions, index: 0,
2020
+ timestamp: timestamp, tempDir: tempDir)) { $1 }
1904
2021
 
1905
2022
  DispatchQueue.main.async {
1906
2023
  self.currentGalleryVC = nil
@@ -1916,6 +2033,7 @@ class RNAttachmentKit: RCTEventEmitter {
1916
2033
  let maxWidth = galleryVC.maxWidth
1917
2034
  let maxHeight = galleryVC.maxHeight
1918
2035
  let format = galleryVC.outputFormat
2036
+ let thumbOptions = galleryThumbnail
1919
2037
 
1920
2038
  DispatchQueue.global(qos: .userInitiated).async {
1921
2039
  let photoManager = PhotoManager.shared
@@ -1962,7 +2080,8 @@ class RNAttachmentKit: RCTEventEmitter {
1962
2080
  result["fileSize"] = meta.fileSize
1963
2081
  }
1964
2082
 
1965
- if (maxWidth > 0 || maxHeight > 0), let resizedImage = self.resizeImage(finalImage, maxWidth: maxWidth, maxHeight: maxHeight) {
2083
+ let resizedForThumb = (maxWidth > 0 || maxHeight > 0) ? self.resizeImage(finalImage, maxWidth: maxWidth, maxHeight: maxHeight) : nil
2084
+ if let resizedImage = resizedForThumb {
1966
2085
  if let resizedUri = self.saveImage(resizedImage, format: format, prefix: "resized", index: index, timestamp: timestamp, tempDir: tempDir) {
1967
2086
  result["uri"] = resizedUri
1968
2087
  result["width"] = Int(resizedImage.size.width)
@@ -1973,6 +2092,8 @@ class RNAttachmentKit: RCTEventEmitter {
1973
2092
  result["fileSize"] = resizedMeta.fileSize
1974
2093
  }
1975
2094
  }
2095
+ result.merge(self.makePhotoThumbnail(resizedForThumb ?? finalImage, options: thumbOptions, index: index,
2096
+ timestamp: timestamp, tempDir: tempDir)) { $1 }
1976
2097
 
1977
2098
  resultsLock.lock()
1978
2099
  results.append(result)
@@ -2018,6 +2139,29 @@ class RNAttachmentKit: RCTEventEmitter {
2018
2139
  }
2019
2140
  }
2020
2141
 
2142
+ /// GalleryOptions.thumbnail / EditorOptions.thumbnail
2143
+ struct PhotoThumbnailOptions {
2144
+ let maxWidth: Int
2145
+ let maxHeight: Int
2146
+ /// true = "cover" (상자를 꽉 채우고 가운데 기준으로 자름), false = "fit"
2147
+ let cover: Bool
2148
+ let quality: CGFloat
2149
+ /// "webp" | "jpg"
2150
+ let format: String
2151
+
2152
+ /// RN 옵션 → 값. maxWidth 가 없거나 0 이하면 nil (썸네일 안 만듦)
2153
+ init?(_ raw: Any?) {
2154
+ guard let d = raw as? NSDictionary, let mw = (d["maxWidth"] as? NSNumber)?.intValue, mw > 0 else { return nil }
2155
+ maxWidth = mw
2156
+ let mh = (d["maxHeight"] as? NSNumber)?.intValue ?? 0
2157
+ maxHeight = mh > 0 ? mh : mw
2158
+ cover = (d["mode"] as? String)?.lowercased() == "cover"
2159
+ quality = CGFloat(min(max((d["quality"] as? NSNumber)?.doubleValue ?? 0.8, 0), 1))
2160
+ let f = (d["format"] as? String)?.lowercased()
2161
+ format = (f == "jpg" || f == "jpeg") ? "jpg" : "webp"
2162
+ }
2163
+ }
2164
+
2021
2165
  // MARK: - UIColor Extension for Hex Support
2022
2166
  extension UIColor {
2023
2167
  convenience init?(hexString: String) {
@@ -2059,6 +2203,10 @@ extension RNAttachmentKit {
2059
2203
  resolve: @escaping RCTPromiseResolveBlock,
2060
2204
  reject: @escaping RCTPromiseRejectBlock) -> Void {
2061
2205
  let o = options as? [String: Any] ?? [:]
2206
+ // 동영상 항목은 라이선스 키가 있어야
2207
+ if !LicenseGate.isLicensed, (o["items"] as? [[String: Any]] ?? []).compactMap({ MediaGalleryItem(dictionary: $0) }).contains(where: { $0.isVideo }) {
2208
+ reject(LicenseGate.errorCode, LicenseGate.errorMessage, nil); return
2209
+ }
2062
2210
  DispatchQueue.main.async {
2063
2211
  let config = MediaGalleryConfig(dictionary: o)
2064
2212
  let galleryId = config.galleryId
@@ -2140,6 +2288,9 @@ extension RNAttachmentKit {
2140
2288
  let galleryId = p["galleryId"] as? String ?? ""
2141
2289
  let raw = p["items"] as? [[String: Any]] ?? []
2142
2290
  let items = raw.compactMap { MediaGalleryItem(dictionary: $0) }
2291
+ if !LicenseGate.isLicensed, items.contains(where: { $0.isVideo }) {
2292
+ reject(LicenseGate.errorCode, LicenseGate.errorMessage, nil); return
2293
+ }
2143
2294
  let page = MediaGalleryPage(items: items, nextCursor: p["nextCursor"] as? String,
2144
2295
  hasMore: (p["hasMore"] as? NSNumber)?.boolValue ?? false)
2145
2296
  DispatchQueue.main.async {