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.
- package/CHANGELOG.md +8 -0
- package/README.md +41 -3
- package/android/CMakeLists.txt +3 -0
- package/android/build.gradle +11 -2
- package/android/src/main/libs/rnattachmentkit-release.aar +0 -0
- package/android/src/newarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +48 -2
- package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +48 -2
- package/ios/RNAttachmentKit.swift +160 -5
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Assets.car +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.abi.json +1283 -810
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +12 -1
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftinterface +12 -1
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/RNAttachmentKit +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeResources +12 -12
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeSignature +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Assets.car +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +1283 -810
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +12 -1
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftinterface +12 -1
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +1283 -810
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +12 -1
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +12 -1
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/RNAttachmentKit +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeResources +22 -22
- package/package.json +2 -2
- package/src/index.ts +1 -1
- 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
|
-
|
|
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
|
-
|
|
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 {
|