react-native-attachment-kit 1.0.6 → 1.0.9

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 +8 -0
  2. package/README.md +41 -3
  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 +160 -5
  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 +1283 -810
  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 +1283 -810
  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 +1283 -810
  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 +2 -2
  31. package/src/index.ts +1 -1
  32. package/src/types.ts +34 -1
@@ -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
@@ -56,6 +57,8 @@ class RNAttachmentKit: RCTEventEmitter {
56
57
  /// 동영상 변환 진행 HUD 를 띄울지 — video.showProgressHUD, 없으면 asyncProcessing 이 아닐 때만
57
58
  /// (앱이 onProcessingStart / onVideoProgress / onImageProcessed 로 말풍선에 진행 상황을 그리는 구조면 HUD 없이)
58
59
  private var galleryShowsVideoHUD = true
60
+ /// 사진 썸네일 (GalleryOptions.thumbnail). nil 이면 만들지 않음 (기존과 같음)
61
+ private var galleryThumbnail: PhotoThumbnailOptions?
59
62
 
60
63
  // MARK: - Module Setup
61
64
 
@@ -239,6 +242,9 @@ class RNAttachmentKit: RCTEventEmitter {
239
242
  func openMapViewer(_ options: NSDictionary,
240
243
  resolve: @escaping RCTPromiseResolveBlock,
241
244
  reject: @escaping RCTPromiseRejectBlock) -> Void {
245
+ guard LicenseGate.isLicensed else {
246
+ reject(LicenseGate.errorCode, LicenseGate.errorMessage, nil); return
247
+ }
242
248
  guard let lat = RNAttachmentKit.cgFloat(options["latitude"]),
243
249
  let lng = RNAttachmentKit.cgFloat(options["longitude"]) else {
244
250
  reject("ERROR", "latitude and longitude are required", nil)
@@ -307,6 +313,9 @@ class RNAttachmentKit: RCTEventEmitter {
307
313
  func renderLocationThumbnail(_ options: NSDictionary,
308
314
  resolve: @escaping RCTPromiseResolveBlock,
309
315
  reject: @escaping RCTPromiseRejectBlock) -> Void {
316
+ guard LicenseGate.isLicensed else {
317
+ reject(LicenseGate.errorCode, LicenseGate.errorMessage, nil); return
318
+ }
310
319
  guard let lat = RNAttachmentKit.cgFloat(options["latitude"]),
311
320
  let lng = RNAttachmentKit.cgFloat(options["longitude"]),
312
321
  let w = RNAttachmentKit.cgFloat(options["width"]), w > 0 else {
@@ -365,6 +374,9 @@ class RNAttachmentKit: RCTEventEmitter {
365
374
  func reverseGeocode(_ options: NSDictionary,
366
375
  resolve: @escaping RCTPromiseResolveBlock,
367
376
  reject: @escaping RCTPromiseRejectBlock) -> Void {
377
+ guard LicenseGate.isLicensed else {
378
+ reject(LicenseGate.errorCode, LicenseGate.errorMessage, nil); return
379
+ }
368
380
  let lat = (options["latitude"] as? NSNumber)?.doubleValue ?? .nan
369
381
  let lng = (options["longitude"] as? NSNumber)?.doubleValue ?? .nan
370
382
  let lang = (options["languageCode"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "en"
@@ -382,6 +394,10 @@ class RNAttachmentKit: RCTEventEmitter {
382
394
  resolve: @escaping RCTPromiseResolveBlock,
383
395
  reject: @escaping RCTPromiseRejectBlock) -> Void {
384
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
+ }
385
401
  DispatchQueue.main.async {
386
402
  AIBilling.apply(dict)
387
403
  AIBilling.onUsage = { [weak self] body in self?.sendEvent(withName: "onAIUsage", body: body) }
@@ -420,6 +436,10 @@ class RNAttachmentKit: RCTEventEmitter {
420
436
  let cacheFiles: [String]? = (options["cacheFiles"] as? [Any])?.map { ($0 as? String) ?? "" }
421
437
  // videos: images 와 같은 순서. 항목이 있으면 그 페이지는 동영상 (images[i] 는 포스터)
422
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
+ }
423
443
  let themeColorHex = options["themeColor"] as? String
424
444
  let languageCode = options["languageCode"] as? String ?? "en"
425
445
  let viewerTitle = options["title"] as? String
@@ -678,6 +698,7 @@ class RNAttachmentKit: RCTEventEmitter {
678
698
  var parsedMaxHeight: Int? = nil
679
699
  if let maxW = options["maxWidth"] as? Int { parsedMaxWidth = maxW }
680
700
  if let maxH = options["maxHeight"] as? Int { parsedMaxHeight = maxH }
701
+ let parsedThumbnail = PhotoThumbnailOptions(options["thumbnail"])
681
702
 
682
703
  DispatchQueue.main.async {
683
704
  guard let rootViewController = self.getRootViewController() else {
@@ -715,7 +736,7 @@ class RNAttachmentKit: RCTEventEmitter {
715
736
 
716
737
  editorVC.onConfirm = { [weak self, weak editorVC] (editedAsset, editedImage, filterState, caption) in
717
738
  editorVC?.dismiss(animated: true) {
718
- 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
719
740
  if let arr = resultArr as? [[String: Any]], let first = arr.first {
720
741
  resolve(first)
721
742
  } else {
@@ -759,7 +780,7 @@ class RNAttachmentKit: RCTEventEmitter {
759
780
 
760
781
  editorVC.onConfirm = { [weak self, weak editorVC] (editedAsset, editedImage, filterState, caption) in
761
782
  editorVC?.dismiss(animated: true) {
762
- self?.processEditorResult(asset: editedAsset, croppedImage: editedImage, filterState: filterState, caption: caption, galleryVC: nil, targetMaxWidth: parsedMaxWidth, targetMaxHeight: parsedMaxHeight, resolve: { resultArr in
783
+ self?.processEditorResult(asset: editedAsset, croppedImage: editedImage, filterState: filterState, caption: caption, galleryVC: nil, targetMaxWidth: parsedMaxWidth, targetMaxHeight: parsedMaxHeight, thumbnail: parsedThumbnail, resolve: { resultArr in
763
784
  var resultDict = (resultArr as? [[String: Any]])?.first ?? (resultArr as? [String: Any]) ?? [:]
764
785
  // For remote URLs, we preserve originalUri if editedFileUri isn't provided
765
786
  let originalRemoteUrl = (options["editedFileUri"] as? String) ?? uri
@@ -867,6 +888,8 @@ class RNAttachmentKit: RCTEventEmitter {
867
888
  // maxWidth, maxHeight 옵션 (미설정 또는 0이면 원본 크기 유지)
868
889
  galleryVC.maxWidth = (options["maxWidth"] as? Int) ?? 0
869
890
  galleryVC.maxHeight = (options["maxHeight"] as? Int) ?? 0
891
+ // 사진 썸네일 (동영상 포스터 규칙은 그대로 — video.posterFormat, 긴 변 512)
892
+ self.galleryThumbnail = PhotoThumbnailOptions(options["thumbnail"])
870
893
 
871
894
  // outputFormat: "webp" | "jpg" | "png" (기본값: "webp")
872
895
  let fmt = (options["outputFormat"] as? String)?.lowercased() ?? "webp"
@@ -972,6 +995,7 @@ class RNAttachmentKit: RCTEventEmitter {
972
995
  // 동영상: mediaType "photo"(기본) | "video" | "all" + video 옵션 (지시서 3-1)
973
996
  let mediaTypeOption = (options["mediaType"] as? String)?.lowercased() ?? "photo"
974
997
  galleryVC.mediaType = ["photo", "video", "all"].contains(mediaTypeOption) ? mediaTypeOption : "photo"
998
+ // 동영상은 라이선스 키가 있어야 — 없으면 갤러리가 사진만 보여 줌 (GalleryBaseViewController.viewDidLoad)
975
999
  let videoOptions = VideoExportOptions(dictionary: options["video"] as? [String: Any])
976
1000
  let asyncProcessing = options["asyncProcessing"] as? Bool ?? false
977
1001
  self.galleryShowsVideoHUD = ((options["video"] as? [String: Any])?["showProgressHUD"] as? Bool) ?? !asyncProcessing
@@ -1003,6 +1027,7 @@ class RNAttachmentKit: RCTEventEmitter {
1003
1027
  let galleryFormat = galleryVC.outputFormat // "webp" | "jpg" | "png"
1004
1028
  let galleryAccent = galleryVC.parsedThemeColor
1005
1029
  let galleryLanguage = galleryVC.languageCode
1030
+ let galleryThumb = self.galleryThumbnail
1006
1031
  galleryVC.onImagesSelected = { [weak self, weak galleryVC] selectedImages in
1007
1032
  guard let self = self else { resolve([]); return }
1008
1033
 
@@ -1175,6 +1200,9 @@ class RNAttachmentKit: RCTEventEmitter {
1175
1200
  result["fileExtension"] = meta.fileExtension
1176
1201
  result["fileSize"] = meta.fileSize
1177
1202
  }
1203
+ // 썸네일: 같은 비트맵(방향·크롭·그리기 반영)에서
1204
+ result.merge(self.makePhotoThumbnail(targetImage, options: galleryThumb, index: index,
1205
+ timestamp: timestamp, tempDir: tempDir)) { $1 }
1178
1206
 
1179
1207
  resultsLock.lock()
1180
1208
  results.append(result)
@@ -1226,6 +1254,9 @@ class RNAttachmentKit: RCTEventEmitter {
1226
1254
  result["fileExtension"] = meta.fileExtension
1227
1255
  result["fileSize"] = meta.fileSize
1228
1256
  }
1257
+ // 썸네일: 같은 비트맵(방향·크롭·그리기 반영)에서
1258
+ result.merge(self.makePhotoThumbnail(targetImage, options: galleryThumb, index: index,
1259
+ timestamp: timestamp, tempDir: tempDir)) { $1 }
1229
1260
 
1230
1261
  resultsLock.lock()
1231
1262
  results.append(result)
@@ -1339,7 +1370,10 @@ class RNAttachmentKit: RCTEventEmitter {
1339
1370
  "width": Int(size.width), "height": Int(size.height),
1340
1371
  "duration": state.outputDuration(sourceDuration: a.duration),
1341
1372
  ]
1342
- if let poster = image {
1373
+ // 앞을 잘랐으면 포스터는 잘린 구간의 첫 프레임 (원본 첫 프레임은 결과 영상에 없음 — Android 와 같음)
1374
+ let trimmedStart = state.trimRange(duration: a.duration).map { CMTimeGetSeconds($0.start) } ?? 0
1375
+ let base = trimmedStart > 0.01 ? (Self.videoFrame(a, at: trimmedStart) ?? image) : image
1376
+ if let poster = base {
1343
1377
  let edited = state.applyEdits(to: poster)
1344
1378
  if let encoded = VideoExporter.encodePoster(edited, format: videoOptions.posterFormat, quality: 0.8) {
1345
1379
  let path = (tempDir as NSString).appendingPathComponent("turbo_video_poster_\(Int(Date().timeIntervalSince1970 * 1000))_\(index).\(encoded.fileExtension)")
@@ -1368,6 +1402,28 @@ class RNAttachmentKit: RCTEventEmitter {
1368
1402
  }
1369
1403
 
1370
1404
  /// 동영상 포스터 (긴 변 512, 동기) — 처리 시작 이벤트용
1405
+ /// 동영상의 [seconds] 프레임 (긴 변 ≤ 1024, 회전 반영). 백그라운드 스레드에서만 — 원본을 받는 동안 최대 3초 기다림
1406
+ private static func videoFrame(_ asset: PHAsset, at seconds: Double) -> UIImage? {
1407
+ let o = PHVideoRequestOptions()
1408
+ o.isNetworkAccessAllowed = true
1409
+ o.deliveryMode = .automatic
1410
+ var avAsset: AVAsset?
1411
+ let done = DispatchSemaphore(value: 0)
1412
+ PHImageManager.default().requestAVAsset(forVideo: asset, options: o) { a, _, _ in
1413
+ avAsset = a
1414
+ done.signal()
1415
+ }
1416
+ guard done.wait(timeout: .now() + 3) == .success, let av = avAsset else { return nil }
1417
+ let gen = AVAssetImageGenerator(asset: av)
1418
+ gen.appliesPreferredTrackTransform = true
1419
+ gen.maximumSize = CGSize(width: 1024, height: 1024)
1420
+ // 잘린 구간 밖(앞) 프레임이 나오지 않게 — 뒤로만 조금 허용
1421
+ gen.requestedTimeToleranceBefore = .zero
1422
+ gen.requestedTimeToleranceAfter = CMTime(seconds: 0.1, preferredTimescale: 600)
1423
+ guard let cg = try? gen.copyCGImage(at: CMTime(seconds: seconds, preferredTimescale: 600), actualTime: nil) else { return nil }
1424
+ return UIImage(cgImage: cg)
1425
+ }
1426
+
1371
1427
  private static func requestPoster(_ asset: PHAsset) -> UIImage? {
1372
1428
  let o = PHImageRequestOptions()
1373
1429
  o.isSynchronous = true
@@ -1595,6 +1651,66 @@ class RNAttachmentKit: RCTEventEmitter {
1595
1651
  }
1596
1652
  }
1597
1653
 
1654
+ // MARK: - Photo thumbnail (GalleryOptions.thumbnail / EditorOptions.thumbnail)
1655
+
1656
+ /// 결과 크기와 원본에서 잘라 쓸 영역 (픽셀). Android PhotoThumbnail.layout 과 같은 계산
1657
+ /// - fit: ratio = min(maxW/w, maxH/h, 1) → round(w·ratio) × round(h·ratio), 자르지 않음
1658
+ /// - cover: 상자 비율로 가운데를 자른 뒤 maxW×maxH 로 줄임. 원본이 상자보다 작으면 확대하지 않고 자른 크기 그대로
1659
+ static func photoThumbnailLayout(width w: Int, height h: Int, options o: PhotoThumbnailOptions) -> (crop: CGRect, size: CGSize) {
1660
+ let dw = Double(w), dh = Double(h)
1661
+ if !o.cover {
1662
+ let r = min(Double(o.maxWidth) / dw, Double(o.maxHeight) / dh, 1)
1663
+ return (CGRect(x: 0, y: 0, width: w, height: h),
1664
+ CGSize(width: max(1, (dw * r).rounded()), height: max(1, (dh * r).rounded())))
1665
+ }
1666
+ let aspect = Double(o.maxWidth) / Double(o.maxHeight)
1667
+ let cropW: Double, cropH: Double
1668
+ if dw / dh > aspect { cropH = dh; cropW = dh * aspect } else { cropW = dw; cropH = dw / aspect }
1669
+ let left = ((dw - cropW) / 2).rounded(), top = ((dh - cropH) / 2).rounded()
1670
+ let crop = CGRect(x: left, y: top, width: min(dw - left, cropW.rounded()), height: min(dh - top, cropH.rounded()))
1671
+ let size = cropW > Double(o.maxWidth)
1672
+ ? CGSize(width: o.maxWidth, height: o.maxHeight)
1673
+ : CGSize(width: max(1, cropW.rounded()), height: max(1, cropH.rounded()))
1674
+ return (crop, size)
1675
+ }
1676
+
1677
+ /// 사진 썸네일을 만들어 결과에 넣을 필드(thumbnailUri/Width/Height/FileSize)를 돌려줌. options 가 없거나 실패하면 빈 딕셔너리.
1678
+ /// image 는 결과 사진(uri)을 만든 것과 같은 UIImage — 원본을 다시 읽지 않음. 새로 그려 인코딩하므로 EXIF·GPS 는 들어가지 않음
1679
+ private func makePhotoThumbnail(_ image: UIImage, options: PhotoThumbnailOptions?, index: Int,
1680
+ timestamp: Int, tempDir: String) -> [String: Any] {
1681
+ guard let o = options else { return [:] }
1682
+ // 방향(imageOrientation) 반영된 픽셀 크기
1683
+ let w = Int((image.size.width * image.scale).rounded()), h = Int((image.size.height * image.scale).rounded())
1684
+ guard w > 0, h > 0 else { return [:] }
1685
+ let (crop, size) = Self.photoThumbnailLayout(width: w, height: h, options: o)
1686
+ let format = UIGraphicsImageRendererFormat()
1687
+ format.scale = 1
1688
+ format.opaque = false
1689
+ format.preferredRange = .standard
1690
+ let sx = size.width / crop.width, sy = size.height / crop.height
1691
+ let thumb = UIGraphicsImageRenderer(size: size, format: format).image { ctx in
1692
+ ctx.cgContext.interpolationQuality = .high
1693
+ image.draw(in: CGRect(x: -crop.minX * sx, y: -crop.minY * sy, width: CGFloat(w) * sx, height: CGFloat(h) * sy))
1694
+ }
1695
+ // 색 프로필(sRGB ICC, 약 3KB)을 붙이지 않도록 장치 RGB 로 태그 — 수 KB 짜리 썸네일에선 용량의 절반 가까이라.
1696
+ // 프로필이 없는 이미지는 sRGB 로 읽히므로 색은 같음
1697
+ let untagged = thumb.cgImage.flatMap { $0.copy(colorSpace: CGColorSpaceCreateDeviceRGB()) }.map { UIImage(cgImage: $0) } ?? thumb
1698
+ // webp 인코딩이 안 되면 jpg 로
1699
+ var data: Data? = nil
1700
+ var ext = "jpg"
1701
+ if o.format == "webp", let d = encodeWebP(image: untagged, quality: o.quality) { data = d; ext = "webp" }
1702
+ if data == nil { data = untagged.jpegData(compressionQuality: o.quality); ext = "jpg" }
1703
+ guard let bytes = data else { return [:] }
1704
+ let url = URL(fileURLWithPath: (tempDir as NSString).appendingPathComponent("turbo_picker_thumb_\(timestamp)_\(index).\(ext)"))
1705
+ do { try bytes.write(to: url, options: [.atomic]) } catch { return [:] }
1706
+ return [
1707
+ "thumbnailUri": url.absoluteString,
1708
+ "thumbnailWidth": Int(size.width),
1709
+ "thumbnailHeight": Int(size.height),
1710
+ "thumbnailFileSize": bytes.count,
1711
+ ]
1712
+ }
1713
+
1598
1714
  /// 이미지를 지정 포맷으로 저장하고 file:// URI 반환
1599
1715
  /// - Parameters:
1600
1716
  /// - image: 저장할 UIImage
@@ -1780,7 +1896,7 @@ class RNAttachmentKit: RCTEventEmitter {
1780
1896
  }
1781
1897
 
1782
1898
 
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) {
1899
+ 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) {
1784
1900
  let tempDir = prepareCacheDirectory()
1785
1901
  // 동영상 1장 편집 → 전송: 편집 화면에서 고른 품질·음소거로 VideoExporter (사진 경로와 같은 결과 형태)
1786
1902
  if let phAsset = asset, phAsset.mediaType == .video {
@@ -1790,6 +1906,8 @@ class RNAttachmentKit: RCTEventEmitter {
1790
1906
  let maxWidth = targetMaxWidth ?? galleryVC?.maxWidth ?? 1024
1791
1907
  let maxHeight = targetMaxHeight ?? galleryVC?.maxHeight ?? 1024
1792
1908
  let format = galleryVC?.outputFormat ?? "webp"
1909
+ // openEditor 는 자기 옵션, 갤러리 편집 경로는 openGallery 의 thumbnail
1910
+ let thumbOptions = thumbnail ?? (galleryVC != nil ? galleryThumbnail : nil)
1793
1911
 
1794
1912
  DispatchQueue.global(qos: .userInitiated).async {
1795
1913
  let photoManager = PhotoManager.shared
@@ -1875,6 +1993,9 @@ class RNAttachmentKit: RCTEventEmitter {
1875
1993
  result["fileExtension"] = finalMeta.fileExtension
1876
1994
  result["fileSize"] = finalMeta.fileSize
1877
1995
  }
1996
+ // 썸네일: 크롭·그리기·필터가 반영된 결과(uri 와 같은 비트맵)에서
1997
+ result.merge(self.makePhotoThumbnail(finalImageForFormat, options: thumbOptions, index: 0,
1998
+ timestamp: timestamp, tempDir: tempDir)) { $1 }
1878
1999
 
1879
2000
  DispatchQueue.main.async {
1880
2001
  self.currentGalleryVC = nil
@@ -1890,6 +2011,7 @@ class RNAttachmentKit: RCTEventEmitter {
1890
2011
  let maxWidth = galleryVC.maxWidth
1891
2012
  let maxHeight = galleryVC.maxHeight
1892
2013
  let format = galleryVC.outputFormat
2014
+ let thumbOptions = galleryThumbnail
1893
2015
 
1894
2016
  DispatchQueue.global(qos: .userInitiated).async {
1895
2017
  let photoManager = PhotoManager.shared
@@ -1936,7 +2058,8 @@ class RNAttachmentKit: RCTEventEmitter {
1936
2058
  result["fileSize"] = meta.fileSize
1937
2059
  }
1938
2060
 
1939
- if (maxWidth > 0 || maxHeight > 0), let resizedImage = self.resizeImage(finalImage, maxWidth: maxWidth, maxHeight: maxHeight) {
2061
+ let resizedForThumb = (maxWidth > 0 || maxHeight > 0) ? self.resizeImage(finalImage, maxWidth: maxWidth, maxHeight: maxHeight) : nil
2062
+ if let resizedImage = resizedForThumb {
1940
2063
  if let resizedUri = self.saveImage(resizedImage, format: format, prefix: "resized", index: index, timestamp: timestamp, tempDir: tempDir) {
1941
2064
  result["uri"] = resizedUri
1942
2065
  result["width"] = Int(resizedImage.size.width)
@@ -1947,6 +2070,8 @@ class RNAttachmentKit: RCTEventEmitter {
1947
2070
  result["fileSize"] = resizedMeta.fileSize
1948
2071
  }
1949
2072
  }
2073
+ result.merge(self.makePhotoThumbnail(resizedForThumb ?? finalImage, options: thumbOptions, index: index,
2074
+ timestamp: timestamp, tempDir: tempDir)) { $1 }
1950
2075
 
1951
2076
  resultsLock.lock()
1952
2077
  results.append(result)
@@ -1992,6 +2117,29 @@ class RNAttachmentKit: RCTEventEmitter {
1992
2117
  }
1993
2118
  }
1994
2119
 
2120
+ /// GalleryOptions.thumbnail / EditorOptions.thumbnail
2121
+ struct PhotoThumbnailOptions {
2122
+ let maxWidth: Int
2123
+ let maxHeight: Int
2124
+ /// true = "cover" (상자를 꽉 채우고 가운데 기준으로 자름), false = "fit"
2125
+ let cover: Bool
2126
+ let quality: CGFloat
2127
+ /// "webp" | "jpg"
2128
+ let format: String
2129
+
2130
+ /// RN 옵션 → 값. maxWidth 가 없거나 0 이하면 nil (썸네일 안 만듦)
2131
+ init?(_ raw: Any?) {
2132
+ guard let d = raw as? NSDictionary, let mw = (d["maxWidth"] as? NSNumber)?.intValue, mw > 0 else { return nil }
2133
+ maxWidth = mw
2134
+ let mh = (d["maxHeight"] as? NSNumber)?.intValue ?? 0
2135
+ maxHeight = mh > 0 ? mh : mw
2136
+ cover = (d["mode"] as? String)?.lowercased() == "cover"
2137
+ quality = CGFloat(min(max((d["quality"] as? NSNumber)?.doubleValue ?? 0.8, 0), 1))
2138
+ let f = (d["format"] as? String)?.lowercased()
2139
+ format = (f == "jpg" || f == "jpeg") ? "jpg" : "webp"
2140
+ }
2141
+ }
2142
+
1995
2143
  // MARK: - UIColor Extension for Hex Support
1996
2144
  extension UIColor {
1997
2145
  convenience init?(hexString: String) {
@@ -2033,6 +2181,10 @@ extension RNAttachmentKit {
2033
2181
  resolve: @escaping RCTPromiseResolveBlock,
2034
2182
  reject: @escaping RCTPromiseRejectBlock) -> Void {
2035
2183
  let o = options as? [String: Any] ?? [:]
2184
+ // 동영상 항목은 라이선스 키가 있어야
2185
+ if !LicenseGate.isLicensed, (o["items"] as? [[String: Any]] ?? []).compactMap({ MediaGalleryItem(dictionary: $0) }).contains(where: { $0.isVideo }) {
2186
+ reject(LicenseGate.errorCode, LicenseGate.errorMessage, nil); return
2187
+ }
2036
2188
  DispatchQueue.main.async {
2037
2189
  let config = MediaGalleryConfig(dictionary: o)
2038
2190
  let galleryId = config.galleryId
@@ -2114,6 +2266,9 @@ extension RNAttachmentKit {
2114
2266
  let galleryId = p["galleryId"] as? String ?? ""
2115
2267
  let raw = p["items"] as? [[String: Any]] ?? []
2116
2268
  let items = raw.compactMap { MediaGalleryItem(dictionary: $0) }
2269
+ if !LicenseGate.isLicensed, items.contains(where: { $0.isVideo }) {
2270
+ reject(LicenseGate.errorCode, LicenseGate.errorMessage, nil); return
2271
+ }
2117
2272
  let page = MediaGalleryPage(items: items, nextCursor: p["nextCursor"] as? String,
2118
2273
  hasMore: (p["hasMore"] as? NSNumber)?.boolValue ?? false)
2119
2274
  DispatchQueue.main.async {