react-native-attachment-kit 1.0.18 → 1.1.1
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 +15 -0
- package/README.md +4 -2
- package/android/build.gradle +8 -3
- package/android/src/main/libs/rnattachmentkit-release.aar +0 -0
- package/android/src/newarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +82 -341
- package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +82 -341
- package/ios/RNAttachmentKit.swift +84 -771
- 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 +6709 -449
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +110 -0
- 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 +110 -0
- 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/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +6709 -449
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +110 -0
- 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 +110 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +6709 -449
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +110 -0
- 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 +110 -0
- 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 +20 -20
- package/package.json +1 -1
- package/src/index.ts +4 -3
- package/src/types.ts +28 -0
|
@@ -51,7 +51,7 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
51
51
|
/// 열려 있는 미디어 갤러리 (galleryId → 화면)
|
|
52
52
|
fileprivate var mediaGalleries: [String: MediaGalleryViewController] = [:]
|
|
53
53
|
/// 진행 중인 전송 처리 (processingId → 작업, 메인 스레드에서만). 보내는 중에 갤러리를 다시 열어 또 보낼 수 있어 여러 개일 수 있음
|
|
54
|
-
private var processingJobs: [String:
|
|
54
|
+
private var processingJobs: [String: AttachmentProcessingJob] = [:]
|
|
55
55
|
/// 가장 최근에 시작한 처리 (cancelVideoProcessing 에 processingId 가 없을 때)
|
|
56
56
|
private var lastProcessingId: String?
|
|
57
57
|
/// 동영상 변환 진행 HUD 를 띄울지 — video.showProgressHUD, 없으면 asyncProcessing 이 아닐 때만
|
|
@@ -175,6 +175,13 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
175
175
|
} else if let r = optDict["sourceBorderRadius"] as? Double {
|
|
176
176
|
self.zoomAnimator.sourceBorderRadius = CGFloat(r)
|
|
177
177
|
}
|
|
178
|
+
// 잘라내기 영역 — 키가 있을 때만 바꿈 (null = 자르지 않음)
|
|
179
|
+
if optDict.keys.contains("sourceClipRect") {
|
|
180
|
+
self.zoomAnimator.sourceClipRect = RNAttachmentKit.parseRect(optDict["sourceClipRect"])
|
|
181
|
+
}
|
|
182
|
+
if optDict.keys.contains("sourceClipInsets") {
|
|
183
|
+
self.zoomAnimator.sourceClipInsets = RNAttachmentKit.parseInsets(optDict["sourceClipInsets"])
|
|
184
|
+
}
|
|
178
185
|
self.zoomAnimator.updateMaskView()
|
|
179
186
|
|
|
180
187
|
resolve(true)
|
|
@@ -203,6 +210,13 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
203
210
|
return CGRect(x: x, y: y, width: w, height: h)
|
|
204
211
|
}
|
|
205
212
|
|
|
213
|
+
/// { top, bottom, left, right } (빠진 값 0). 객체가 아니면 nil
|
|
214
|
+
private static func parseInsets(_ v: Any?) -> UIEdgeInsets? {
|
|
215
|
+
guard let d = v as? NSDictionary else { return nil }
|
|
216
|
+
return UIEdgeInsets(top: cgFloat(d["top"]) ?? 0, left: cgFloat(d["left"]) ?? 0,
|
|
217
|
+
bottom: cgFloat(d["bottom"]) ?? 0, right: cgFloat(d["right"]) ?? 0)
|
|
218
|
+
}
|
|
219
|
+
|
|
206
220
|
/// 말풍선 이미지(정적 지도 스냅샷) 로드. 메모리/디스크 캐시 → file:// → 다운로드 순.
|
|
207
221
|
/// 실패하거나 너무 오래 걸리면 nil (이미지 없이 지도만 엶)
|
|
208
222
|
/// completion(이미지, 마커 뺀 이미지인지). renderLocationThumbnail 결과면 마커 뺀 짝을 씀 → 뷰어가 마커를 따로 그려
|
|
@@ -495,6 +509,9 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
495
509
|
parsedRect = CGRect(x: x, y: y, width: w, height: h)
|
|
496
510
|
}
|
|
497
511
|
|
|
512
|
+
let sourceClipRect = RNAttachmentKit.parseRect(options["sourceClipRect"])
|
|
513
|
+
let sourceClipInsets = RNAttachmentKit.parseInsets(options["sourceClipInsets"])
|
|
514
|
+
|
|
498
515
|
var sourceBorderRadius: CGFloat = 0
|
|
499
516
|
if let radiusNum = options["sourceBorderRadius"] as? NSNumber {
|
|
500
517
|
sourceBorderRadius = CGFloat(truncating: radiusNum)
|
|
@@ -551,6 +568,9 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
551
568
|
self.zoomAnimator.sourceBorderRadius = sourceBorderRadius
|
|
552
569
|
self.zoomAnimator.sourceBorderCorners = mask
|
|
553
570
|
self.zoomAnimator.sourceBackgroundColor = sourceBackgroundColor
|
|
571
|
+
// 애니메이터는 재사용되므로 이번 호출 값으로 덮어씀 (안 주면 자르지 않음)
|
|
572
|
+
self.zoomAnimator.sourceClipRect = sourceClipRect
|
|
573
|
+
self.zoomAnimator.sourceClipInsets = sourceClipInsets
|
|
554
574
|
// Reset any sourceImage left over from a previous openViewer() call - it's only
|
|
555
575
|
// meant to carry this call's local placeholder (see below), and must not leak
|
|
556
576
|
// into a later call that has no placeholder of its own.
|
|
@@ -1033,255 +1053,62 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
1033
1053
|
return
|
|
1034
1054
|
}
|
|
1035
1055
|
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
let maxHeight = galleryMaxHeight
|
|
1040
|
-
let format = galleryFormat
|
|
1041
|
-
let mimeType = self.mimeType(for: format)
|
|
1042
|
-
let needResize = (maxWidth > 0 || maxHeight > 0)
|
|
1056
|
+
let processingOptions = AttachmentProcessingOptions(maxWidth: galleryMaxWidth, maxHeight: galleryMaxHeight,
|
|
1057
|
+
outputFormat: galleryFormat, thumbnail: galleryThumb,
|
|
1058
|
+
video: videoOptions)
|
|
1043
1059
|
let total = selectedImages.count
|
|
1044
1060
|
|
|
1045
|
-
let resultsLock = NSLock()
|
|
1046
|
-
// 처리한 이미지는 바로 놓아 주도록 (전체 UIImage 배열을 끝까지 들고 있지 않게) 옵셔널 배열로 옮김
|
|
1047
|
-
var pending: [(PHAsset?, UIImage)?] = selectedImages.map { $0 }
|
|
1048
|
-
|
|
1049
1061
|
// 동영상이 섞여 있으면 변환 진행 표시 (갤러리가 닫힌 뒤에도 보이게 키 윈도우에) + 취소
|
|
1050
1062
|
let hasVideo = selectedImages.contains { $0.0?.mediaType == .video }
|
|
1051
1063
|
// 편집 화면에서 고른 품질·음소거 (없으면 기본 품질)
|
|
1052
1064
|
let videoStates = galleryVC?.videoEditStates ?? [:]
|
|
1053
1065
|
var hud: VideoExportHUD?
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
let exporterLock = NSLock()
|
|
1057
|
-
// 앱이 동영상 하나(또는 전부)를 취소할 수 있게 (cancelVideoProcessing)
|
|
1058
|
-
let job = MediaProcessingJob()
|
|
1066
|
+
// 앱이 동영상 하나(또는 전부)를 취소할 수 있게 (cancelVideoProcessing). HUD 의 취소는 전체 중단(abort)
|
|
1067
|
+
let job = AttachmentProcessingJob()
|
|
1059
1068
|
let processingId = UUID().uuidString
|
|
1060
1069
|
self.processingJobs[processingId] = job
|
|
1061
1070
|
self.lastProcessingId = processingId
|
|
1062
|
-
// 처리 시작 이벤트용 — 고른 순서의 원본(사진은 편집 결과) 크기·포스터
|
|
1063
|
-
let startItems = selectedImages.map { ($0.0, $0.1) }
|
|
1064
1071
|
if hasVideo && self.galleryShowsVideoHUD {
|
|
1065
1072
|
hud = VideoExportHUD.show(accent: galleryAccent, languageCode: galleryLanguage)
|
|
1066
|
-
hud?.onCancel = {
|
|
1067
|
-
exporterLock.lock(); cancelledAll = true; currentExporter?.cancel(); exporterLock.unlock()
|
|
1068
|
-
}
|
|
1073
|
+
hud?.onCancel = { job.abort() }
|
|
1069
1074
|
}
|
|
1070
1075
|
|
|
1076
|
+
// 고르기·가공은 AttachmentProcessor (앱의 네이티브 화면과 같은 코드)
|
|
1071
1077
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1078
|
+
let (results, aborted) = AttachmentProcessor.process(
|
|
1079
|
+
selectedImages, videoStates: videoStates, options: processingOptions, job: job,
|
|
1080
|
+
onStart: { items in
|
|
1081
|
+
// ✅ 처리 시작: 모든 자리(사진·동영상)를 먼저 알림 → 앱이 말풍선을 바로 만들고 진행률을 그림
|
|
1082
|
+
self.sendEvent(withName: "onProcessingStart", body: [
|
|
1083
|
+
"processingId": processingId,
|
|
1084
|
+
"total": total,
|
|
1085
|
+
"items": items.map { $0.dictionary },
|
|
1086
|
+
])
|
|
1080
1087
|
},
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
// ✅ 동영상: VideoExporter 로 전송용 mp4 (+ 썸네일·메타). image 는 포스터(목록용)
|
|
1094
|
-
let exporter = VideoExporter()
|
|
1095
|
-
exporterLock.lock(); currentExporter = exporter; exporterLock.unlock()
|
|
1096
|
-
let base = "turbo_video_\(timestamp)_\(index)"
|
|
1097
|
-
var result: [String: Any] = [
|
|
1098
|
-
"originalUri": "ph://\(phAsset.localIdentifier)",
|
|
1099
|
-
"originalWidth": phAsset.pixelWidth,
|
|
1100
|
-
"originalHeight": phAsset.pixelHeight,
|
|
1101
|
-
"mediaType": "video",
|
|
1102
|
-
"type": "video/mp4",
|
|
1103
|
-
"duration": phAsset.duration,
|
|
1104
|
-
]
|
|
1105
|
-
do {
|
|
1106
|
-
// 앱이 이미 취소한 동영상은 건너뜀 (결과에 cancelled 로)
|
|
1107
|
-
if job.isCancelled(index) { throw VideoExporter.ExportError.cancelled }
|
|
1108
|
-
job.begin(index: index, exporter: exporter)
|
|
1109
|
-
let state = videoStates[phAsset.localIdentifier] ?? VideoEditState.defaultState(for: phAsset, options: videoOptions)
|
|
1110
|
-
let throttle = ProgressThrottle()
|
|
1111
|
-
let onProgress: (Double) -> Void = { p in
|
|
1112
|
-
// 프레임마다 메인으로 보내지 않고 1%·0.1초 단위로만 (HUD 표시는 정수 % 라 같음)
|
|
1113
|
-
guard throttle.shouldSend(p) else { return }
|
|
1114
|
-
DispatchQueue.main.async { hud?.update(progress: p, index: index, total: total) }
|
|
1115
|
-
self.sendEvent(withName: "onVideoProgress", body: ["index": index, "total": total, "progress": p, "processingId": processingId])
|
|
1116
|
-
}
|
|
1117
|
-
// 편집 화면에서 미리 만들어 둔 파일이 있으면(또는 만드는 중이면 끝나고) 그대로 씀
|
|
1118
|
-
let r: VideoExportResult
|
|
1119
|
-
// 기다리는 동안 앱(X)이나 HUD 가 취소하면 바로 멈춤 (전엔 미리 만들기가 끝날 때까지 못 빠져나왔음)
|
|
1120
|
-
let stop = { () -> Bool in
|
|
1121
|
-
exporterLock.lock(); let all = cancelledAll; exporterLock.unlock()
|
|
1122
|
-
return all || job.isCancelled(index)
|
|
1123
|
-
}
|
|
1124
|
-
if let ready = VideoPrepareManager.shared.takeReady(asset: phAsset, state: state, progress: onProgress, isCancelled: stop) {
|
|
1125
|
-
r = ready; onProgress(1)
|
|
1126
|
-
} else {
|
|
1127
|
-
if stop() { throw VideoExporter.ExportError.cancelled }
|
|
1128
|
-
r = try exporter.export(asset: phAsset, state: state,
|
|
1129
|
-
options: videoOptions, outputDir: URL(fileURLWithPath: tempDir), baseName: base, progress: onProgress)
|
|
1130
|
-
}
|
|
1131
|
-
// 미리 만든 파일을 기다리는 동안 취소됐으면 결과를 쓰지 않음
|
|
1132
|
-
if job.isCancelled(index) { throw VideoExporter.ExportError.cancelled }
|
|
1133
|
-
result["uri"] = "file://" + r.url.path
|
|
1134
|
-
result["width"] = r.width
|
|
1135
|
-
result["height"] = r.height
|
|
1136
|
-
result["duration"] = r.duration
|
|
1137
|
-
result["fileSize"] = r.fileSize
|
|
1138
|
-
result["fileName"] = r.url.lastPathComponent
|
|
1139
|
-
result["fileExtension"] = "mp4"
|
|
1140
|
-
result["videoCodec"] = r.codec
|
|
1141
|
-
result["videoBitrate"] = r.bitrate
|
|
1142
|
-
result["edited"] = !r.passthrough
|
|
1143
|
-
if let t = r.thumbnailURL {
|
|
1144
|
-
result["thumbnailUri"] = "file://" + t.path
|
|
1145
|
-
result["thumbnailWidth"] = r.thumbnailWidth
|
|
1146
|
-
result["thumbnailHeight"] = r.thumbnailHeight
|
|
1147
|
-
}
|
|
1148
|
-
} catch VideoExporter.ExportError.cancelled where job.isCancelled(index) {
|
|
1149
|
-
// 앱이 이 동영상만 취소 → 나머지는 계속
|
|
1150
|
-
result["error"] = "CANCELLED"
|
|
1151
|
-
result["cancelled"] = true
|
|
1152
|
-
} catch VideoExporter.ExportError.cancelled {
|
|
1153
|
-
// HUD 의 취소 → 전체 취소 (Promise reject)
|
|
1154
|
-
job.end()
|
|
1155
|
-
exporterLock.lock(); cancelledAll = true; exporterLock.unlock()
|
|
1156
|
-
return
|
|
1157
|
-
} catch {
|
|
1158
|
-
result["error"] = error.localizedDescription
|
|
1159
|
-
}
|
|
1160
|
-
job.end()
|
|
1161
|
-
exporterLock.lock(); currentExporter = nil; exporterLock.unlock()
|
|
1162
|
-
|
|
1163
|
-
resultsLock.lock()
|
|
1164
|
-
results.append(result)
|
|
1165
|
-
let currentResult = result
|
|
1166
|
-
resultsLock.unlock()
|
|
1167
|
-
self.sendEvent(withName: "onImageProcessed", body: ["index": index, "total": total, "image": currentResult, "processingId": processingId])
|
|
1168
|
-
} else if let phAsset = asset {
|
|
1169
|
-
// ✅ 1순위: PHAsset
|
|
1170
|
-
let originalUri = "ph://\(phAsset.localIdentifier)"
|
|
1171
|
-
|
|
1172
|
-
var targetImage: UIImage
|
|
1173
|
-
if needResize, let resized = self.resizeImage(image, maxWidth: maxWidth, maxHeight: maxHeight) {
|
|
1174
|
-
targetImage = resized
|
|
1175
|
-
} else {
|
|
1176
|
-
targetImage = image
|
|
1177
|
-
}
|
|
1178
|
-
targetImage = ImageProcessor.shared.applyWatermarkIfNeeded(targetImage)
|
|
1179
|
-
|
|
1180
|
-
var result: [String: Any] = [
|
|
1181
|
-
"originalUri": originalUri,
|
|
1182
|
-
"originalWidth": Int(originalSize.width),
|
|
1183
|
-
"originalHeight": Int(originalSize.height),
|
|
1184
|
-
"type": mimeType
|
|
1185
|
-
]
|
|
1186
|
-
|
|
1187
|
-
if let savedUri = self.saveImage(targetImage, format: format,
|
|
1188
|
-
prefix: "resized",
|
|
1189
|
-
index: index, timestamp: timestamp,
|
|
1190
|
-
tempDir: tempDir) {
|
|
1191
|
-
result["uri"] = savedUri
|
|
1192
|
-
result["width"] = Int(targetImage.size.width)
|
|
1193
|
-
result["height"] = Int(targetImage.size.height)
|
|
1194
|
-
let meta = self.fileMetadata(fromPath: savedUri)
|
|
1195
|
-
result["fileName"] = meta.fileName
|
|
1196
|
-
result["fileExtension"] = meta.fileExtension
|
|
1197
|
-
result["fileSize"] = meta.fileSize
|
|
1198
|
-
}
|
|
1199
|
-
// 썸네일: 같은 비트맵(방향·크롭·그리기 반영)에서
|
|
1200
|
-
result.merge(self.makePhotoThumbnail(targetImage, options: galleryThumb, index: index,
|
|
1201
|
-
timestamp: timestamp, tempDir: tempDir)) { $1 }
|
|
1202
|
-
|
|
1203
|
-
resultsLock.lock()
|
|
1204
|
-
results.append(result)
|
|
1205
|
-
let currentResult = result
|
|
1206
|
-
resultsLock.unlock()
|
|
1207
|
-
|
|
1208
|
-
self.sendEvent(withName: "onImageProcessed", body: [
|
|
1209
|
-
"index": index,
|
|
1210
|
-
"total": total,
|
|
1211
|
-
"image": currentResult,
|
|
1212
|
-
"processingId": processingId
|
|
1213
|
-
])
|
|
1214
|
-
} else {
|
|
1215
|
-
// ✅ 2순위: 카메라 촬영본
|
|
1216
|
-
guard let originalFilePath = self.saveOriginalImageAsJPEG(
|
|
1217
|
-
image, index: index, timestamp: timestamp, tempDir: tempDir) else {
|
|
1218
|
-
return
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
var targetImage: UIImage
|
|
1222
|
-
if needResize, let resized = self.resizeImage(image, maxWidth: maxWidth, maxHeight: maxHeight) {
|
|
1223
|
-
targetImage = resized
|
|
1224
|
-
} else {
|
|
1225
|
-
targetImage = image
|
|
1226
|
-
}
|
|
1227
|
-
targetImage = ImageProcessor.shared.applyWatermarkIfNeeded(targetImage)
|
|
1228
|
-
|
|
1229
|
-
var result: [String: Any] = [
|
|
1230
|
-
"originalUri": originalFilePath,
|
|
1231
|
-
"originalFileUri": originalFilePath,
|
|
1232
|
-
"originalWidth": Int(originalSize.width),
|
|
1233
|
-
"originalHeight": Int(originalSize.height),
|
|
1234
|
-
"type": mimeType
|
|
1235
|
-
]
|
|
1236
|
-
let origMeta = self.fileMetadata(fromPath: originalFilePath)
|
|
1237
|
-
result["fileName"] = origMeta.fileName
|
|
1238
|
-
result["fileExtension"] = origMeta.fileExtension
|
|
1239
|
-
result["fileSize"] = origMeta.fileSize
|
|
1240
|
-
|
|
1241
|
-
if let savedUri = self.saveImage(targetImage, format: format,
|
|
1242
|
-
prefix: "resized",
|
|
1243
|
-
index: index, timestamp: timestamp,
|
|
1244
|
-
tempDir: tempDir) {
|
|
1245
|
-
result["uri"] = savedUri
|
|
1246
|
-
result["width"] = Int(targetImage.size.width)
|
|
1247
|
-
result["height"] = Int(targetImage.size.height)
|
|
1248
|
-
let meta = self.fileMetadata(fromPath: savedUri)
|
|
1249
|
-
result["fileName"] = meta.fileName
|
|
1250
|
-
result["fileExtension"] = meta.fileExtension
|
|
1251
|
-
result["fileSize"] = meta.fileSize
|
|
1252
|
-
}
|
|
1253
|
-
// 썸네일: 같은 비트맵(방향·크롭·그리기 반영)에서
|
|
1254
|
-
result.merge(self.makePhotoThumbnail(targetImage, options: galleryThumb, index: index,
|
|
1255
|
-
timestamp: timestamp, tempDir: tempDir)) { $1 }
|
|
1256
|
-
|
|
1257
|
-
resultsLock.lock()
|
|
1258
|
-
results.append(result)
|
|
1259
|
-
let currentResult = result
|
|
1260
|
-
resultsLock.unlock()
|
|
1261
|
-
|
|
1262
|
-
self.sendEvent(withName: "onImageProcessed", body: [
|
|
1263
|
-
"index": index,
|
|
1264
|
-
"total": total,
|
|
1265
|
-
"image": currentResult,
|
|
1266
|
-
"processingId": processingId
|
|
1267
|
-
])
|
|
1268
|
-
}
|
|
1269
|
-
} // end autoreleasepool
|
|
1270
|
-
} // end for
|
|
1088
|
+
onVideoProgress: { index, p in
|
|
1089
|
+
DispatchQueue.main.async { hud?.update(progress: p, index: index, total: total) }
|
|
1090
|
+
self.sendEvent(withName: "onVideoProgress", body: ["index": index, "total": total, "progress": p, "processingId": processingId])
|
|
1091
|
+
},
|
|
1092
|
+
onItem: { r in
|
|
1093
|
+
self.sendEvent(withName: "onImageProcessed", body: [
|
|
1094
|
+
"index": r.index,
|
|
1095
|
+
"total": total,
|
|
1096
|
+
"image": r.dictionary,
|
|
1097
|
+
"processingId": processingId
|
|
1098
|
+
])
|
|
1099
|
+
})
|
|
1271
1100
|
|
|
1272
1101
|
DispatchQueue.main.async {
|
|
1273
1102
|
hud?.dismiss()
|
|
1274
1103
|
// 처리하는 동안 새 갤러리가 열렸을 수 있음 → 내 갤러리일 때만 비움
|
|
1275
1104
|
if self.currentGalleryVC == nil || self.currentGalleryVC === galleryVC { self.currentGalleryVC = nil }
|
|
1276
1105
|
self.processingJobs[processingId] = nil
|
|
1277
|
-
|
|
1278
|
-
if wasCancelled {
|
|
1106
|
+
if aborted {
|
|
1279
1107
|
// 변환 취소: 이미 만든 파일은 버림
|
|
1280
|
-
results.compactMap {
|
|
1281
|
-
.forEach { try? FileManager.default.removeItem(atPath: $0) }
|
|
1108
|
+
results.compactMap { $0.path }.forEach { try? FileManager.default.removeItem(atPath: $0) }
|
|
1282
1109
|
reject("CANCELLED", "User cancelled video conversion", nil)
|
|
1283
1110
|
} else {
|
|
1284
|
-
resolve(results)
|
|
1111
|
+
resolve(results.map { $0.dictionary })
|
|
1285
1112
|
}
|
|
1286
1113
|
// 결과를 넘긴 뒤: 갤러리의 PHCachingImageManager 캐시·콜백 해제 (닫힘 애니메이션에는 영향 없음).
|
|
1287
1114
|
// 새 갤러리가 열려 있으면 그 화면이 미리 인코딩 중인 파일은 건드리지 않음
|
|
@@ -1354,86 +1181,6 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
1354
1181
|
}
|
|
1355
1182
|
}
|
|
1356
1183
|
|
|
1357
|
-
/// onProcessingStart 의 한 자리: 사진은 예상 결과 크기, 동영상은 예상 결과 크기·길이(구간 반영)·포스터(크롭·그리기 반영 jpg)
|
|
1358
|
-
private func processingStartItem(index: Int, asset: PHAsset?, image: UIImage?, videoStates: [String: VideoEditState],
|
|
1359
|
-
videoOptions: VideoExportOptions, maxWidth: Int, maxHeight: Int, tempDir: String) -> [String: Any] {
|
|
1360
|
-
if let a = asset, a.mediaType == .video {
|
|
1361
|
-
let state = videoStates[a.localIdentifier] ?? VideoEditState.defaultState(for: a, options: videoOptions)
|
|
1362
|
-
let d = CGSize(width: a.pixelWidth, height: a.pixelHeight)
|
|
1363
|
-
let size = VideoExporter.predictedOutputSize(displaySize: d, state: state, options: videoOptions, sourceDuration: a.duration)
|
|
1364
|
-
var item: [String: Any] = [
|
|
1365
|
-
"index": index, "mediaType": "video", "originalUri": "ph://\(a.localIdentifier)",
|
|
1366
|
-
"width": Int(size.width), "height": Int(size.height),
|
|
1367
|
-
"duration": state.outputDuration(sourceDuration: a.duration),
|
|
1368
|
-
]
|
|
1369
|
-
// 포스터는 보낼 영상의 첫 프레임 — 앞을 잘랐으면 잘린 구간의 첫 프레임 (원본 첫 프레임은 결과 영상에 없음 — Android 와 같음).
|
|
1370
|
-
// 자르지 않았을 때도 갤러리 썸네일(사진 앱이 정한 대표 화면 — 첫 화면이 아닐 수 있음)이 아니라 0초 프레임을 씀. 못 읽으면 갤러리 썸네일
|
|
1371
|
-
let trimmedStart = state.trimRange(duration: a.duration).map { CMTimeGetSeconds($0.start) } ?? 0
|
|
1372
|
-
let base = Self.videoFrame(a, at: trimmedStart) ?? image
|
|
1373
|
-
if let poster = base {
|
|
1374
|
-
let edited = state.applyEdits(to: poster)
|
|
1375
|
-
if let encoded = VideoExporter.encodePoster(edited, format: videoOptions.posterFormat, quality: 0.8) {
|
|
1376
|
-
let path = (tempDir as NSString).appendingPathComponent("turbo_video_poster_\(Int(Date().timeIntervalSince1970 * 1000))_\(index).\(encoded.fileExtension)")
|
|
1377
|
-
if (try? encoded.data.write(to: URL(fileURLWithPath: path))) != nil {
|
|
1378
|
-
item["thumbnailUri"] = "file://" + path
|
|
1379
|
-
item["thumbnailWidth"] = Int(edited.size.width * edited.scale)
|
|
1380
|
-
item["thumbnailHeight"] = Int(edited.size.height * edited.scale)
|
|
1381
|
-
}
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1384
|
-
return item
|
|
1385
|
-
}
|
|
1386
|
-
// 사진: 리사이즈 규칙(resizeImage)과 같은 예상 크기 (resizeImage 처럼 image.size 기준)
|
|
1387
|
-
var w = image?.size.width ?? CGFloat(asset?.pixelWidth ?? 0)
|
|
1388
|
-
var h = image?.size.height ?? CGFloat(asset?.pixelHeight ?? 0)
|
|
1389
|
-
if w > 0, h > 0 {
|
|
1390
|
-
var ratio: CGFloat = 1
|
|
1391
|
-
if maxWidth > 0 && maxHeight > 0 { ratio = min(CGFloat(maxWidth) / w, CGFloat(maxHeight) / h, 1) }
|
|
1392
|
-
else if maxWidth > 0 { ratio = min(CGFloat(maxWidth) / w, 1) }
|
|
1393
|
-
else if maxHeight > 0 { ratio = min(CGFloat(maxHeight) / h, 1) }
|
|
1394
|
-
w *= ratio; h *= ratio
|
|
1395
|
-
}
|
|
1396
|
-
var item: [String: Any] = ["index": index, "mediaType": "image", "width": Int(w.rounded()), "height": Int(h.rounded())]
|
|
1397
|
-
if let a = asset { item["originalUri"] = "ph://\(a.localIdentifier)" }
|
|
1398
|
-
return item
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
/// 동영상 포스터 (긴 변 512, 동기) — 처리 시작 이벤트용
|
|
1402
|
-
/// 동영상의 [seconds] 프레임 (긴 변 ≤ 1024, 회전 반영). 백그라운드 스레드에서만 — 원본을 받는 동안 최대 3초 기다림
|
|
1403
|
-
private static func videoFrame(_ asset: PHAsset, at seconds: Double) -> UIImage? {
|
|
1404
|
-
let o = PHVideoRequestOptions()
|
|
1405
|
-
o.isNetworkAccessAllowed = true
|
|
1406
|
-
o.deliveryMode = .automatic
|
|
1407
|
-
var avAsset: AVAsset?
|
|
1408
|
-
let done = DispatchSemaphore(value: 0)
|
|
1409
|
-
PHImageManager.default().requestAVAsset(forVideo: asset, options: o) { a, _, _ in
|
|
1410
|
-
avAsset = a
|
|
1411
|
-
done.signal()
|
|
1412
|
-
}
|
|
1413
|
-
guard done.wait(timeout: .now() + 3) == .success, let av = avAsset else { return nil }
|
|
1414
|
-
let gen = AVAssetImageGenerator(asset: av)
|
|
1415
|
-
gen.appliesPreferredTrackTransform = true
|
|
1416
|
-
gen.maximumSize = CGSize(width: 1024, height: 1024)
|
|
1417
|
-
// 잘린 구간 밖(앞) 프레임이 나오지 않게 — 뒤로만 조금 허용
|
|
1418
|
-
gen.requestedTimeToleranceBefore = .zero
|
|
1419
|
-
gen.requestedTimeToleranceAfter = CMTime(seconds: 0.1, preferredTimescale: 600)
|
|
1420
|
-
guard let cg = try? gen.copyCGImage(at: CMTime(seconds: seconds, preferredTimescale: 600), actualTime: nil) else { return nil }
|
|
1421
|
-
return UIImage(cgImage: cg)
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
private static func requestPoster(_ asset: PHAsset) -> UIImage? {
|
|
1425
|
-
let o = PHImageRequestOptions()
|
|
1426
|
-
o.isSynchronous = true
|
|
1427
|
-
o.deliveryMode = .highQualityFormat
|
|
1428
|
-
o.resizeMode = .fast
|
|
1429
|
-
o.isNetworkAccessAllowed = true
|
|
1430
|
-
var out: UIImage?
|
|
1431
|
-
PHImageManager.default().requestImage(for: asset, targetSize: CGSize(width: 512, height: 512), contentMode: .aspectFit, options: o) { img, _ in
|
|
1432
|
-
out = img
|
|
1433
|
-
}
|
|
1434
|
-
return out
|
|
1435
|
-
}
|
|
1436
|
-
|
|
1437
1184
|
@objc
|
|
1438
1185
|
func closeGallery(_ resolve: @escaping RCTPromiseResolveBlock,
|
|
1439
1186
|
reject: @escaping RCTPromiseRejectBlock) -> Void {
|
|
@@ -1470,309 +1217,8 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
1470
1217
|
|
|
1471
1218
|
// MARK: - Private Methods
|
|
1472
1219
|
|
|
1473
|
-
|
|
1474
|
-
/// picker 결과물은 일시적으로만 필요하므로 cache 사용
|
|
1475
|
-
private var cacheDirectory: String {
|
|
1476
|
-
return FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
|
|
1477
|
-
.first?.appendingPathComponent("RNAttachmentKit").path
|
|
1478
|
-
?? NSTemporaryDirectory()
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
|
-
/// 캐시 디렉토리 준비 (없으면 생성)
|
|
1482
|
-
private func prepareCacheDirectory() -> String {
|
|
1483
|
-
let dir = cacheDirectory
|
|
1484
|
-
if !FileManager.default.fileExists(atPath: dir) {
|
|
1485
|
-
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
|
1486
|
-
}
|
|
1487
|
-
return dir
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
// 이미지 리사이징 (비율 유지)
|
|
1491
|
-
private func resizeImage(_ image: UIImage, maxWidth: Int, maxHeight: Int) -> UIImage? {
|
|
1492
|
-
let originalSize = image.size
|
|
1493
|
-
var targetSize = originalSize
|
|
1494
|
-
|
|
1495
|
-
// maxWidth와 maxHeight 둘 다 사용 (기본값 500이므로 항상 리사이징)
|
|
1496
|
-
if maxWidth > 0 && maxHeight > 0 {
|
|
1497
|
-
// 둘 다 설정된 경우: 더 작은 비율로 리사이징
|
|
1498
|
-
let widthRatio = CGFloat(maxWidth) / originalSize.width
|
|
1499
|
-
let heightRatio = CGFloat(maxHeight) / originalSize.height
|
|
1500
|
-
let ratio = min(widthRatio, heightRatio, 1.0) // 1.0 이상이면 리사이징 안 함
|
|
1501
|
-
|
|
1502
|
-
if ratio < 1.0 {
|
|
1503
|
-
targetSize = CGSize(width: originalSize.width * ratio, height: originalSize.height * ratio)
|
|
1504
|
-
}
|
|
1505
|
-
} else if maxWidth > 0 && originalSize.width > CGFloat(maxWidth) {
|
|
1506
|
-
// maxWidth만 설정된 경우
|
|
1507
|
-
let ratio = CGFloat(maxWidth) / originalSize.width
|
|
1508
|
-
targetSize = CGSize(width: CGFloat(maxWidth), height: originalSize.height * ratio)
|
|
1509
|
-
} else if maxHeight > 0 && originalSize.height > CGFloat(maxHeight) {
|
|
1510
|
-
// maxHeight만 설정된 경우
|
|
1511
|
-
let ratio = CGFloat(maxHeight) / originalSize.height
|
|
1512
|
-
targetSize = CGSize(width: originalSize.width * ratio, height: CGFloat(maxHeight))
|
|
1513
|
-
} else {
|
|
1514
|
-
// 리사이징 불필요
|
|
1515
|
-
return nil
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
|
-
// 같은 크기면 리사이징 안 함
|
|
1519
|
-
if targetSize.width >= originalSize.width && targetSize.height >= originalSize.height {
|
|
1520
|
-
return nil
|
|
1521
|
-
}
|
|
1522
|
-
|
|
1523
|
-
// 이미지 리사이징 (UIGraphicsImageRenderer 방식 - 메모리 최적화)
|
|
1524
|
-
let format = UIGraphicsImageRendererFormat()
|
|
1525
|
-
format.scale = 1.0 // 실제 픽셀 크기로 저장하기 위함
|
|
1526
|
-
format.opaque = false
|
|
1527
|
-
if #available(iOS 12.0, *) {
|
|
1528
|
-
format.preferredRange = .standard // 광색역 대신 표준 색역 사용으로 메모리 절약
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
|
|
1532
|
-
return renderer.image { _ in
|
|
1533
|
-
image.draw(in: CGRect(origin: .zero, size: targetSize))
|
|
1534
|
-
}
|
|
1535
|
-
}
|
|
1536
|
-
|
|
1537
|
-
// ✅ 2순위: 원본 이미지를 JPEG으로 저장 (WebP 인코딩 없음 → 빠름)
|
|
1538
|
-
// 카메라 촬영본 및 에디터 결과물에 사용
|
|
1539
|
-
private func saveOriginalImageAsJPEG(_ image: UIImage, index: Int, timestamp: Int, tempDir: String) -> String? {
|
|
1540
|
-
let fileName = "turbo_picker_original_\(timestamp)_\(index).jpg"
|
|
1541
|
-
let filePath = (tempDir as NSString).appendingPathComponent(fileName)
|
|
1542
|
-
let fileURL = URL(fileURLWithPath: filePath)
|
|
1543
|
-
|
|
1544
|
-
if let jpegData = image.jpegData(compressionQuality: 0.9) {
|
|
1545
|
-
do {
|
|
1546
|
-
try jpegData.write(to: fileURL, options: [.atomic])
|
|
1547
|
-
return fileURL.absoluteString
|
|
1548
|
-
} catch {
|
|
1549
|
-
print("❌ [TurboImagePicker] JPEG 원본 이미지 저장 실패: \(error)")
|
|
1550
|
-
}
|
|
1551
|
-
}
|
|
1552
|
-
return nil
|
|
1553
|
-
}
|
|
1554
|
-
|
|
1555
|
-
// 리사이즈된 이미지 저장 (WebP 포맷)
|
|
1556
|
-
private func saveResizedImage(_ image: UIImage, index: Int, timestamp: Int, tempDir: String) -> String? {
|
|
1557
|
-
let fileName = "turbo_picker_resized_\(timestamp)_\(index).webp"
|
|
1558
|
-
let filePath = (tempDir as NSString).appendingPathComponent(fileName)
|
|
1559
|
-
let fileURL = URL(fileURLWithPath: filePath)
|
|
1560
|
-
|
|
1561
|
-
if let data = encodeWebP(image: image, quality: 0.85) {
|
|
1562
|
-
do {
|
|
1563
|
-
try data.write(to: fileURL, options: [.atomic])
|
|
1564
|
-
return fileURL.absoluteString
|
|
1565
|
-
} catch {
|
|
1566
|
-
print("❌ [TurboImagePicker] WebP 리사이즈 이미지 저장 실패: \(error)")
|
|
1567
|
-
}
|
|
1568
|
-
}
|
|
1569
|
-
// Fallback to JPEG
|
|
1570
|
-
if let jpegData = image.jpegData(compressionQuality: 0.85) {
|
|
1571
|
-
let fallbackPath = (tempDir as NSString).appendingPathComponent("turbo_picker_resized_\(timestamp)_\(index).jpg")
|
|
1572
|
-
let fallbackURL = URL(fileURLWithPath: fallbackPath)
|
|
1573
|
-
do {
|
|
1574
|
-
try jpegData.write(to: fallbackURL, options: [.atomic])
|
|
1575
|
-
return fallbackURL.absoluteString
|
|
1576
|
-
} catch {}
|
|
1577
|
-
}
|
|
1578
|
-
return nil
|
|
1579
|
-
}
|
|
1580
|
-
// WebP 인코딩 - iOS 16+ 네이티브 CGImageDestination 사용 또는 런타임 Fallback
|
|
1581
|
-
private func encodeWebP(image: UIImage, quality: CGFloat) -> Data? {
|
|
1582
|
-
if #available(iOS 16.0, *) {
|
|
1583
|
-
if let cgImage = image.cgImage {
|
|
1584
|
-
let data = NSMutableData()
|
|
1585
|
-
if let destination = CGImageDestinationCreateWithData(data as CFMutableData, "public.webp" as CFString, 1, nil) {
|
|
1586
|
-
let options: [CFString: Any] = [kCGImageDestinationLossyCompressionQuality: quality]
|
|
1587
|
-
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
|
1588
|
-
if CGImageDestinationFinalize(destination) {
|
|
1589
|
-
#if DEBUG
|
|
1590
|
-
NSLog("✅ [encodeWebP] iOS 16 네이티브 WebP 인코딩 성공 - %d bytes", data.length)
|
|
1591
|
-
#endif
|
|
1592
|
-
return data as Data
|
|
1593
|
-
}
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
|
-
}
|
|
1597
|
-
|
|
1598
|
-
#if DEBUG
|
|
1599
|
-
NSLog("🟣 [encodeWebP] SDImageWebPCoder 런타임 폴백 시도")
|
|
1600
|
-
#endif
|
|
1601
|
-
// iOS 14, 15이거나 네이티브 실패 시 SDImageWebPCoder 런타임 호출
|
|
1602
|
-
if let coderClass = NSClassFromString("SDImageWebPCoder") as? NSObject.Type {
|
|
1603
|
-
if coderClass.responds(to: NSSelectorFromString("sharedCoder")) {
|
|
1604
|
-
let sharedCoder = coderClass.perform(NSSelectorFromString("sharedCoder")).takeUnretainedValue()
|
|
1605
|
-
let encodeSel = NSSelectorFromString("encodedDataWithImage:format:options:")
|
|
1606
|
-
|
|
1607
|
-
if sharedCoder.responds(to: encodeSel),
|
|
1608
|
-
let method = class_getInstanceMethod(type(of: sharedCoder), encodeSel) {
|
|
1609
|
-
|
|
1610
|
-
// ObjC method signature: - (NSData *)encodedDataWithImage:(UIImage *)image format:(NSInteger)format options:(NSDictionary *)options
|
|
1611
|
-
typealias EncodeFunc = @convention(c) (AnyObject, Selector, UIImage?, Int, [String: Any]?) -> Data?
|
|
1612
|
-
let implementation = method_getImplementation(method)
|
|
1613
|
-
let encode = unsafeBitCast(implementation, to: EncodeFunc.self)
|
|
1614
|
-
|
|
1615
|
-
// format: 3 (SDImageFormatWebP)
|
|
1616
|
-
let options: [String: Any] = ["encodeCompressionQuality": quality]
|
|
1617
|
-
if let data = encode(sharedCoder, encodeSel, image, 3, options) {
|
|
1618
|
-
#if DEBUG
|
|
1619
|
-
NSLog("✅ [encodeWebP] SDImageWebPCoder 런타임 인코딩 성공 - %d bytes", data.count)
|
|
1620
|
-
#endif
|
|
1621
|
-
return data
|
|
1622
|
-
} else {
|
|
1623
|
-
NSLog("❌ [encodeWebP] SDImageWebPCoder 런타임 인코딩 실패 (nil 반환)")
|
|
1624
|
-
}
|
|
1625
|
-
} else {
|
|
1626
|
-
NSLog("❌ [encodeWebP] SDImageWebPCoder에 encodedDataWithImage:format:options: 메서드가 없습니다.")
|
|
1627
|
-
}
|
|
1628
|
-
} else {
|
|
1629
|
-
NSLog("❌ [encodeWebP] SDImageWebPCoder에 sharedCoder가 없습니다.")
|
|
1630
|
-
}
|
|
1631
|
-
} else {
|
|
1632
|
-
NSLog("❌ [encodeWebP] SDImageWebPCoder 클래스를 찾을 수 없습니다.")
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
|
-
NSLog("❌ [encodeWebP] 모든 WebP 인코딩 실패 → nil 반환")
|
|
1636
|
-
return nil
|
|
1637
|
-
}
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
// MARK: - Format-aware save helpers
|
|
1220
|
+
// 사진 리사이즈·저장·썸네일·임시 폴더는 AttachmentProcessor (프레임워크 공개 API — 앱 네이티브 화면과 같은 코드)
|
|
1641
1221
|
|
|
1642
|
-
/// outputFormat("webp"|"jpg"|"png")에 맞는 MIME 타입 반환
|
|
1643
|
-
private func mimeType(for format: String) -> String {
|
|
1644
|
-
switch format {
|
|
1645
|
-
case "png": return "image/png"
|
|
1646
|
-
case "jpg": return "image/jpeg"
|
|
1647
|
-
default: return "image/webp"
|
|
1648
|
-
}
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
// MARK: - Photo thumbnail (GalleryOptions.thumbnail / EditorOptions.thumbnail)
|
|
1652
|
-
|
|
1653
|
-
/// 결과 크기와 원본에서 잘라 쓸 영역 (픽셀). Android PhotoThumbnail.layout 과 같은 계산
|
|
1654
|
-
/// - fit: ratio = min(maxW/w, maxH/h, 1) → round(w·ratio) × round(h·ratio), 자르지 않음
|
|
1655
|
-
/// - cover: 상자 비율로 가운데를 자른 뒤 maxW×maxH 로 줄임. 원본이 상자보다 작으면 확대하지 않고 자른 크기 그대로
|
|
1656
|
-
static func photoThumbnailLayout(width w: Int, height h: Int, options o: PhotoThumbnailOptions) -> (crop: CGRect, size: CGSize) {
|
|
1657
|
-
let dw = Double(w), dh = Double(h)
|
|
1658
|
-
if !o.cover {
|
|
1659
|
-
let r = min(Double(o.maxWidth) / dw, Double(o.maxHeight) / dh, 1)
|
|
1660
|
-
return (CGRect(x: 0, y: 0, width: w, height: h),
|
|
1661
|
-
CGSize(width: max(1, (dw * r).rounded()), height: max(1, (dh * r).rounded())))
|
|
1662
|
-
}
|
|
1663
|
-
let aspect = Double(o.maxWidth) / Double(o.maxHeight)
|
|
1664
|
-
let cropW: Double, cropH: Double
|
|
1665
|
-
if dw / dh > aspect { cropH = dh; cropW = dh * aspect } else { cropW = dw; cropH = dw / aspect }
|
|
1666
|
-
let left = ((dw - cropW) / 2).rounded(), top = ((dh - cropH) / 2).rounded()
|
|
1667
|
-
let crop = CGRect(x: left, y: top, width: min(dw - left, cropW.rounded()), height: min(dh - top, cropH.rounded()))
|
|
1668
|
-
let size = cropW > Double(o.maxWidth)
|
|
1669
|
-
? CGSize(width: o.maxWidth, height: o.maxHeight)
|
|
1670
|
-
: CGSize(width: max(1, cropW.rounded()), height: max(1, cropH.rounded()))
|
|
1671
|
-
return (crop, size)
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
|
-
/// 사진 썸네일을 만들어 결과에 넣을 필드(thumbnailUri/Width/Height/FileSize)를 돌려줌. options 가 없거나 실패하면 빈 딕셔너리.
|
|
1675
|
-
/// image 는 결과 사진(uri)을 만든 것과 같은 UIImage — 원본을 다시 읽지 않음. 새로 그려 인코딩하므로 EXIF·GPS 는 들어가지 않음
|
|
1676
|
-
private func makePhotoThumbnail(_ image: UIImage, options: PhotoThumbnailOptions?, index: Int,
|
|
1677
|
-
timestamp: Int, tempDir: String) -> [String: Any] {
|
|
1678
|
-
guard let o = options else { return [:] }
|
|
1679
|
-
// 방향(imageOrientation) 반영된 픽셀 크기
|
|
1680
|
-
let w = Int((image.size.width * image.scale).rounded()), h = Int((image.size.height * image.scale).rounded())
|
|
1681
|
-
guard w > 0, h > 0 else { return [:] }
|
|
1682
|
-
let (crop, size) = Self.photoThumbnailLayout(width: w, height: h, options: o)
|
|
1683
|
-
let format = UIGraphicsImageRendererFormat()
|
|
1684
|
-
format.scale = 1
|
|
1685
|
-
format.opaque = false
|
|
1686
|
-
format.preferredRange = .standard
|
|
1687
|
-
let sx = size.width / crop.width, sy = size.height / crop.height
|
|
1688
|
-
let thumb = UIGraphicsImageRenderer(size: size, format: format).image { ctx in
|
|
1689
|
-
ctx.cgContext.interpolationQuality = .high
|
|
1690
|
-
image.draw(in: CGRect(x: -crop.minX * sx, y: -crop.minY * sy, width: CGFloat(w) * sx, height: CGFloat(h) * sy))
|
|
1691
|
-
}
|
|
1692
|
-
// 색 프로필(sRGB ICC, 약 3KB)을 붙이지 않도록 장치 RGB 로 태그 — 수 KB 짜리 썸네일에선 용량의 절반 가까이라.
|
|
1693
|
-
// 프로필이 없는 이미지는 sRGB 로 읽히므로 색은 같음
|
|
1694
|
-
let untagged = thumb.cgImage.flatMap { $0.copy(colorSpace: CGColorSpaceCreateDeviceRGB()) }.map { UIImage(cgImage: $0) } ?? thumb
|
|
1695
|
-
// webp 인코딩이 안 되면 jpg 로
|
|
1696
|
-
var data: Data? = nil
|
|
1697
|
-
var ext = "jpg"
|
|
1698
|
-
if o.format == "webp", let d = encodeWebP(image: untagged, quality: o.quality) { data = d; ext = "webp" }
|
|
1699
|
-
if data == nil { data = untagged.jpegData(compressionQuality: o.quality); ext = "jpg" }
|
|
1700
|
-
guard let bytes = data else { return [:] }
|
|
1701
|
-
let url = URL(fileURLWithPath: (tempDir as NSString).appendingPathComponent("turbo_picker_thumb_\(timestamp)_\(index).\(ext)"))
|
|
1702
|
-
do { try bytes.write(to: url, options: [.atomic]) } catch { return [:] }
|
|
1703
|
-
return [
|
|
1704
|
-
"thumbnailUri": url.absoluteString,
|
|
1705
|
-
"thumbnailWidth": Int(size.width),
|
|
1706
|
-
"thumbnailHeight": Int(size.height),
|
|
1707
|
-
"thumbnailFileSize": bytes.count,
|
|
1708
|
-
]
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
/// 이미지를 지정 포맷으로 저장하고 file:// URI 반환
|
|
1712
|
-
/// - Parameters:
|
|
1713
|
-
/// - image: 저장할 UIImage
|
|
1714
|
-
/// - format: "webp" | "jpg" | "png"
|
|
1715
|
-
/// - prefix: 파일명 prefix (e.g. "resized", "original")
|
|
1716
|
-
/// - index: 배치 내 인덱스
|
|
1717
|
-
/// - timestamp: 타임스탬프(ms)
|
|
1718
|
-
/// - tempDir: 저장 디렉토리 경로
|
|
1719
|
-
private func saveImage(_ image: UIImage, format: String,
|
|
1720
|
-
prefix: String, index: Int,
|
|
1721
|
-
timestamp: Int, tempDir: String, forceJPEG: Bool = false) -> String? {
|
|
1722
|
-
let ext: String
|
|
1723
|
-
let data: Data?
|
|
1724
|
-
|
|
1725
|
-
let actualFormat = forceJPEG ? "jpg" : format
|
|
1726
|
-
|
|
1727
|
-
switch actualFormat {
|
|
1728
|
-
case "png":
|
|
1729
|
-
ext = "png"
|
|
1730
|
-
data = image.pngData()
|
|
1731
|
-
case "jpg":
|
|
1732
|
-
ext = "jpg"
|
|
1733
|
-
data = image.jpegData(compressionQuality: 0.9)
|
|
1734
|
-
default: // "webp"
|
|
1735
|
-
ext = "webp"
|
|
1736
|
-
data = encodeWebP(image: image, quality: 0.85)
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
|
-
guard let imageData = data else {
|
|
1740
|
-
// WebP 인코딩 실패 시 JPEG fallback
|
|
1741
|
-
if format == "webp", let fallbackData = image.jpegData(compressionQuality: 0.85) {
|
|
1742
|
-
let path = (tempDir as NSString).appendingPathComponent(
|
|
1743
|
-
"turbo_picker_\(prefix)_\(timestamp)_\(index).jpg")
|
|
1744
|
-
let url = URL(fileURLWithPath: path)
|
|
1745
|
-
try? fallbackData.write(to: url)
|
|
1746
|
-
return url.absoluteString
|
|
1747
|
-
}
|
|
1748
|
-
return nil
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1751
|
-
let fileName = "turbo_picker_\(prefix)_\(timestamp)_\(index).\(ext)"
|
|
1752
|
-
let filePath = (tempDir as NSString).appendingPathComponent(fileName)
|
|
1753
|
-
let fileURL = URL(fileURLWithPath: filePath)
|
|
1754
|
-
|
|
1755
|
-
do {
|
|
1756
|
-
try imageData.write(to: fileURL)
|
|
1757
|
-
return fileURL.absoluteString
|
|
1758
|
-
} catch {
|
|
1759
|
-
print("❌ [TurboImagePicker] \(format.uppercased()) 저장 실패: \(error)")
|
|
1760
|
-
return nil
|
|
1761
|
-
}
|
|
1762
|
-
}
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
/// 저장된 파일 경로에서 fileName, fileExtension, fileSize 반환
|
|
1766
|
-
private func fileMetadata(fromPath filePath: String) -> (fileName: String, fileExtension: String, fileSize: Int64) {
|
|
1767
|
-
let url = URL(fileURLWithPath: filePath.hasPrefix("file://")
|
|
1768
|
-
? String(filePath.dropFirst(7))
|
|
1769
|
-
: filePath)
|
|
1770
|
-
let fileName = url.lastPathComponent
|
|
1771
|
-
let fileExtension = url.pathExtension.lowercased()
|
|
1772
|
-
let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0
|
|
1773
|
-
return (fileName, fileExtension, fileSize)
|
|
1774
|
-
}
|
|
1775
|
-
|
|
1776
1222
|
/// 맨 위 컨트롤러에 present. 맨 위가 닫히거나 열리는 중이면(예: openGallery 가 끝난 직후 갤러리 시트가
|
|
1777
1223
|
/// 닫히는 애니메이션) UIKit 이 present 를 조용히 버리므로, 전환이 끝난 뒤 다시 시도합니다.
|
|
1778
1224
|
/// 끝내 present 되지 않으면 reject — 안 그러면 JS 약속이 안 풀리고 _editorOpen 가드에 계속 막힙니다.
|
|
@@ -1832,99 +1278,55 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
1832
1278
|
|
|
1833
1279
|
// MARK: - Editor Result Processing
|
|
1834
1280
|
|
|
1835
|
-
/// 동영상 1장을 전송용 mp4 로 만들어 resolve (갤러리 다중 전송 경로의 동영상 분기와 같은 필드)
|
|
1281
|
+
/// 동영상 1장을 전송용 mp4 로 만들어 resolve (갤러리 다중 전송 경로의 동영상 분기와 같은 필드 — AttachmentProcessor.processVideo)
|
|
1836
1282
|
private func exportSingleVideo(_ phAsset: PHAsset, galleryVC: GalleryViewController?, tempDir: String,
|
|
1837
1283
|
resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
1838
1284
|
let videoOptions = galleryVC?.videoExportOptions ?? VideoExportOptions()
|
|
1839
1285
|
let state = galleryVC?.videoEditStates[phAsset.localIdentifier] ?? VideoEditState.defaultState(for: phAsset, options: videoOptions)
|
|
1840
1286
|
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
|
|
1841
|
-
let
|
|
1842
|
-
|
|
1843
|
-
hud?.onCancel = {
|
|
1844
|
-
let job = MediaProcessingJob()
|
|
1287
|
+
let job = AttachmentProcessingJob()
|
|
1288
|
+
// HUD 취소 = 전체 중단 (Promise reject)
|
|
1289
|
+
hud?.onCancel = { job.abort() }
|
|
1845
1290
|
let processingId = UUID().uuidString
|
|
1846
1291
|
processingJobs[processingId] = job
|
|
1847
1292
|
lastProcessingId = processingId
|
|
1293
|
+
let processingOptions = AttachmentProcessingOptions(video: videoOptions, outputDirectory: tempDir)
|
|
1848
1294
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
1849
|
-
let poster =
|
|
1295
|
+
let poster = AttachmentProcessor.requestPoster(phAsset)
|
|
1850
1296
|
self.sendEvent(withName: "onProcessingStart", body: [
|
|
1851
1297
|
"processingId": processingId,
|
|
1852
1298
|
"total": 1,
|
|
1853
|
-
"items": [
|
|
1854
|
-
|
|
1299
|
+
"items": [AttachmentProcessor.startItem(index: 0, asset: phAsset, image: poster, videoStates: [phAsset.localIdentifier: state],
|
|
1300
|
+
options: processingOptions).dictionary],
|
|
1855
1301
|
])
|
|
1856
|
-
let
|
|
1857
|
-
var result: [String: Any] = [
|
|
1858
|
-
"originalUri": "ph://\(phAsset.localIdentifier)",
|
|
1859
|
-
"originalWidth": phAsset.pixelWidth,
|
|
1860
|
-
"originalHeight": phAsset.pixelHeight,
|
|
1861
|
-
"mediaType": "video",
|
|
1862
|
-
"type": "video/mp4",
|
|
1863
|
-
"duration": phAsset.duration,
|
|
1864
|
-
]
|
|
1302
|
+
let result: AttachmentResult
|
|
1865
1303
|
do {
|
|
1866
|
-
|
|
1867
|
-
let throttle = ProgressThrottle()
|
|
1868
|
-
let onProgress: (Double) -> Void = { p in
|
|
1869
|
-
guard throttle.shouldSend(p) else { return }
|
|
1304
|
+
result = try AttachmentProcessor.processVideo(phAsset, index: 0, state: state, options: processingOptions, job: job, progress: { p in
|
|
1870
1305
|
DispatchQueue.main.async { hud?.update(progress: p, index: 0, total: 1) }
|
|
1871
1306
|
self.sendEvent(withName: "onVideoProgress", body: ["index": 0, "total": 1, "progress": p, "processingId": processingId])
|
|
1872
|
-
}
|
|
1873
|
-
|
|
1874
|
-
// HUD 취소는 exporter.cancel() — 기다리는 동안엔 job 으로 알 수 없으므로 HUD 취소도 job 에 기록
|
|
1875
|
-
if let ready = VideoPrepareManager.shared.takeReady(asset: phAsset, state: state, progress: onProgress,
|
|
1876
|
-
isCancelled: { job.isCancelled(0) || hudCancelled.value }) {
|
|
1877
|
-
r = ready; onProgress(1)
|
|
1878
|
-
} else {
|
|
1879
|
-
if job.isCancelled(0) || hudCancelled.value { throw VideoExporter.ExportError.cancelled }
|
|
1880
|
-
r = try exporter.export(asset: phAsset, state: state, options: videoOptions,
|
|
1881
|
-
outputDir: URL(fileURLWithPath: tempDir), baseName: "turbo_video_\(timestamp)_0", progress: onProgress)
|
|
1882
|
-
}
|
|
1883
|
-
result["uri"] = "file://" + r.url.path
|
|
1884
|
-
result["width"] = r.width
|
|
1885
|
-
result["height"] = r.height
|
|
1886
|
-
result["duration"] = r.duration
|
|
1887
|
-
result["fileSize"] = r.fileSize
|
|
1888
|
-
result["fileName"] = r.url.lastPathComponent
|
|
1889
|
-
result["fileExtension"] = "mp4"
|
|
1890
|
-
result["videoCodec"] = r.codec
|
|
1891
|
-
result["videoBitrate"] = r.bitrate
|
|
1892
|
-
result["edited"] = !r.passthrough
|
|
1893
|
-
if let t = r.thumbnailURL {
|
|
1894
|
-
result["thumbnailUri"] = "file://" + t.path
|
|
1895
|
-
result["thumbnailWidth"] = r.thumbnailWidth
|
|
1896
|
-
result["thumbnailHeight"] = r.thumbnailHeight
|
|
1897
|
-
}
|
|
1898
|
-
if job.isCancelled(0) { throw VideoExporter.ExportError.cancelled }
|
|
1899
|
-
} catch VideoExporter.ExportError.cancelled where job.isCancelled(0) {
|
|
1900
|
-
// 앱이 취소 (cancelVideoProcessing) — 이벤트·결과에 cancelled 로
|
|
1901
|
-
result["error"] = "CANCELLED"
|
|
1902
|
-
result["cancelled"] = true
|
|
1903
|
-
} catch VideoExporter.ExportError.cancelled {
|
|
1904
|
-
job.end()
|
|
1307
|
+
})
|
|
1308
|
+
} catch {
|
|
1905
1309
|
DispatchQueue.main.async {
|
|
1906
1310
|
self.processingJobs[processingId] = nil
|
|
1907
1311
|
hud?.dismiss(); reject("CANCELLED", "Video export cancelled", nil)
|
|
1908
1312
|
}
|
|
1909
1313
|
return
|
|
1910
|
-
} catch {
|
|
1911
|
-
result["error"] = error.localizedDescription
|
|
1912
1314
|
}
|
|
1913
|
-
|
|
1914
|
-
self.sendEvent(withName: "onImageProcessed", body: ["index": 0, "total": 1, "image":
|
|
1315
|
+
let dict = result.dictionary
|
|
1316
|
+
self.sendEvent(withName: "onImageProcessed", body: ["index": 0, "total": 1, "image": dict, "processingId": processingId])
|
|
1915
1317
|
DispatchQueue.main.async {
|
|
1916
1318
|
hud?.dismiss()
|
|
1917
1319
|
if self.currentGalleryVC == nil || self.currentGalleryVC === galleryVC { self.currentGalleryVC = nil }
|
|
1918
1320
|
self.processingJobs[processingId] = nil
|
|
1919
1321
|
galleryVC?.releaseAfterReturn(discardPreparedVideos: self.currentGalleryVC == nil)
|
|
1920
|
-
resolve([
|
|
1322
|
+
resolve([dict])
|
|
1921
1323
|
}
|
|
1922
1324
|
}
|
|
1923
1325
|
}
|
|
1924
1326
|
|
|
1925
1327
|
|
|
1926
1328
|
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) {
|
|
1927
|
-
let tempDir = prepareCacheDirectory()
|
|
1329
|
+
let tempDir = AttachmentProcessor.prepareCacheDirectory()
|
|
1928
1330
|
// 동영상 1장 편집 → 전송: 편집 화면에서 고른 품질·음소거로 VideoExporter (사진 경로와 같은 결과 형태)
|
|
1929
1331
|
if let phAsset = asset, phAsset.mediaType == .video {
|
|
1930
1332
|
exportSingleVideo(phAsset, galleryVC: galleryVC, tempDir: tempDir, resolve: resolve, reject: reject)
|
|
@@ -1961,7 +1363,7 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
1961
1363
|
finalImage = ImageProcessor.shared.applyWatermarkIfNeeded(finalImage)
|
|
1962
1364
|
resultImage = finalImage
|
|
1963
1365
|
// ✅ 2순위: 에디터 결과 원본 저장 (WebP 소프트웨어 인코딩 방지를 위해 하드웨어 가속되는 JPG 강제 사용)
|
|
1964
|
-
originalFilePath =
|
|
1366
|
+
originalFilePath = AttachmentProcessor.saveImage(finalImage, format: format, prefix: "original", index: 0, timestamp: timestamp, tempDir: tempDir, forceJPEG: true)
|
|
1965
1367
|
dispatchGroup.leave()
|
|
1966
1368
|
}
|
|
1967
1369
|
|
|
@@ -1996,32 +1398,32 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
1996
1398
|
"originalUri": originalUri,
|
|
1997
1399
|
"originalWidth": Int(originalSize.width),
|
|
1998
1400
|
"originalHeight": Int(originalSize.height),
|
|
1999
|
-
"type":
|
|
1401
|
+
"type": AttachmentProcessor.mimeType(for: format),
|
|
2000
1402
|
"caption": caption
|
|
2001
1403
|
]
|
|
2002
1404
|
|
|
2003
1405
|
if let originalFilePath = originalFilePath {
|
|
2004
1406
|
result["originalFileUri"] = originalFilePath
|
|
2005
1407
|
result["uri"] = originalFilePath // 기본적으로 uri에도 파일 경로 저장
|
|
2006
|
-
let meta =
|
|
1408
|
+
let meta = AttachmentProcessor.fileMetadata(fromPath: originalFilePath)
|
|
2007
1409
|
result["fileName"] = meta.fileName
|
|
2008
1410
|
result["fileExtension"] = meta.fileExtension
|
|
2009
1411
|
result["fileSize"] = meta.fileSize
|
|
2010
1412
|
}
|
|
2011
1413
|
|
|
2012
|
-
let finalImageForFormat =
|
|
1414
|
+
let finalImageForFormat = AttachmentProcessor.resizeImage(image, maxWidth: maxWidth, maxHeight: maxHeight) ?? image
|
|
2013
1415
|
|
|
2014
|
-
if let finalUri =
|
|
1416
|
+
if let finalUri = AttachmentProcessor.saveImage(finalImageForFormat, format: format, prefix: "final", index: 0, timestamp: timestamp, tempDir: tempDir) {
|
|
2015
1417
|
result["uri"] = finalUri
|
|
2016
1418
|
result["width"] = Int(finalImageForFormat.size.width)
|
|
2017
1419
|
result["height"] = Int(finalImageForFormat.size.height)
|
|
2018
|
-
let finalMeta =
|
|
1420
|
+
let finalMeta = AttachmentProcessor.fileMetadata(fromPath: finalUri)
|
|
2019
1421
|
result["fileName"] = finalMeta.fileName
|
|
2020
1422
|
result["fileExtension"] = finalMeta.fileExtension
|
|
2021
1423
|
result["fileSize"] = finalMeta.fileSize
|
|
2022
1424
|
}
|
|
2023
1425
|
// 썸네일: 크롭·그리기·필터가 반영된 결과(uri 와 같은 비트맵)에서
|
|
2024
|
-
result.merge(
|
|
1426
|
+
result.merge(AttachmentProcessor.photoThumbnailFields(finalImageForFormat, options: thumbOptions, index: 0,
|
|
2025
1427
|
timestamp: timestamp, tempDir: tempDir)) { $1 }
|
|
2026
1428
|
|
|
2027
1429
|
DispatchQueue.main.async {
|
|
@@ -2034,7 +1436,7 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
2034
1436
|
|
|
2035
1437
|
|
|
2036
1438
|
private func processEditorResultMulti(assets: [PHAsset], croppedImages: [PHAsset: UIImage], filterStates: [PHAsset: FilterState], galleryVC: GalleryViewController, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
2037
|
-
let tempDir = prepareCacheDirectory()
|
|
1439
|
+
let tempDir = AttachmentProcessor.prepareCacheDirectory()
|
|
2038
1440
|
let maxWidth = galleryVC.maxWidth
|
|
2039
1441
|
let maxHeight = galleryVC.maxHeight
|
|
2040
1442
|
let format = galleryVC.outputFormat
|
|
@@ -2072,32 +1474,32 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
2072
1474
|
"originalUri": originalUri,
|
|
2073
1475
|
"originalWidth": Int(finalImage.size.width),
|
|
2074
1476
|
"originalHeight": Int(finalImage.size.height),
|
|
2075
|
-
"type":
|
|
1477
|
+
"type": AttachmentProcessor.mimeType(for: format)
|
|
2076
1478
|
]
|
|
2077
1479
|
|
|
2078
1480
|
// ✅ 2순위: 에디터 다중 결과 원본 저장 (JPG 강제 사용으로 속도 최적화)
|
|
2079
|
-
if let originalFilePath =
|
|
1481
|
+
if let originalFilePath = AttachmentProcessor.saveImage(finalImage, format: format, prefix: "original", index: index, timestamp: timestamp, tempDir: tempDir, forceJPEG: true) {
|
|
2080
1482
|
result["originalFileUri"] = originalFilePath
|
|
2081
1483
|
result["uri"] = originalFilePath // 기본적으로 uri에도 파일 경로 저장
|
|
2082
|
-
let meta =
|
|
1484
|
+
let meta = AttachmentProcessor.fileMetadata(fromPath: originalFilePath)
|
|
2083
1485
|
result["fileName"] = meta.fileName
|
|
2084
1486
|
result["fileExtension"] = meta.fileExtension
|
|
2085
1487
|
result["fileSize"] = meta.fileSize
|
|
2086
1488
|
}
|
|
2087
1489
|
|
|
2088
|
-
let resizedForThumb = (maxWidth > 0 || maxHeight > 0) ?
|
|
1490
|
+
let resizedForThumb = (maxWidth > 0 || maxHeight > 0) ? AttachmentProcessor.resizeImage(finalImage, maxWidth: maxWidth, maxHeight: maxHeight) : nil
|
|
2089
1491
|
if let resizedImage = resizedForThumb {
|
|
2090
|
-
if let resizedUri =
|
|
1492
|
+
if let resizedUri = AttachmentProcessor.saveImage(resizedImage, format: format, prefix: "resized", index: index, timestamp: timestamp, tempDir: tempDir) {
|
|
2091
1493
|
result["uri"] = resizedUri
|
|
2092
1494
|
result["width"] = Int(resizedImage.size.width)
|
|
2093
1495
|
result["height"] = Int(resizedImage.size.height)
|
|
2094
|
-
let resizedMeta =
|
|
1496
|
+
let resizedMeta = AttachmentProcessor.fileMetadata(fromPath: resizedUri)
|
|
2095
1497
|
result["fileName"] = resizedMeta.fileName
|
|
2096
1498
|
result["fileExtension"] = resizedMeta.fileExtension
|
|
2097
1499
|
result["fileSize"] = resizedMeta.fileSize
|
|
2098
1500
|
}
|
|
2099
1501
|
}
|
|
2100
|
-
result.merge(
|
|
1502
|
+
result.merge(AttachmentProcessor.photoThumbnailFields(resizedForThumb ?? finalImage, options: thumbOptions, index: index,
|
|
2101
1503
|
timestamp: timestamp, tempDir: tempDir)) { $1 }
|
|
2102
1504
|
|
|
2103
1505
|
resultsLock.lock()
|
|
@@ -2144,29 +1546,6 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
2144
1546
|
}
|
|
2145
1547
|
}
|
|
2146
1548
|
|
|
2147
|
-
/// GalleryOptions.thumbnail / EditorOptions.thumbnail
|
|
2148
|
-
struct PhotoThumbnailOptions {
|
|
2149
|
-
let maxWidth: Int
|
|
2150
|
-
let maxHeight: Int
|
|
2151
|
-
/// true = "cover" (상자를 꽉 채우고 가운데 기준으로 자름), false = "fit"
|
|
2152
|
-
let cover: Bool
|
|
2153
|
-
let quality: CGFloat
|
|
2154
|
-
/// "webp" | "jpg"
|
|
2155
|
-
let format: String
|
|
2156
|
-
|
|
2157
|
-
/// RN 옵션 → 값. maxWidth 가 없거나 0 이하면 nil (썸네일 안 만듦)
|
|
2158
|
-
init?(_ raw: Any?) {
|
|
2159
|
-
guard let d = raw as? NSDictionary, let mw = (d["maxWidth"] as? NSNumber)?.intValue, mw > 0 else { return nil }
|
|
2160
|
-
maxWidth = mw
|
|
2161
|
-
let mh = (d["maxHeight"] as? NSNumber)?.intValue ?? 0
|
|
2162
|
-
maxHeight = mh > 0 ? mh : mw
|
|
2163
|
-
cover = (d["mode"] as? String)?.lowercased() == "cover"
|
|
2164
|
-
quality = CGFloat(min(max((d["quality"] as? NSNumber)?.doubleValue ?? 0.8, 0), 1))
|
|
2165
|
-
let f = (d["format"] as? String)?.lowercased()
|
|
2166
|
-
format = (f == "jpg" || f == "jpeg") ? "jpg" : "webp"
|
|
2167
|
-
}
|
|
2168
|
-
}
|
|
2169
|
-
|
|
2170
1549
|
// MARK: - UIColor Extension for Hex Support
|
|
2171
1550
|
extension UIColor {
|
|
2172
1551
|
convenience init?(hexString: String) {
|
|
@@ -2613,69 +1992,3 @@ extension RNAttachmentKit: UIViewControllerTransitioningDelegate {
|
|
|
2613
1992
|
return zoomAnimator
|
|
2614
1993
|
}
|
|
2615
1994
|
}
|
|
2616
|
-
|
|
2617
|
-
// MARK: - 전송 처리 상태 (앱에서 동영상 취소)
|
|
2618
|
-
|
|
2619
|
-
/// openGallery 결과 처리 한 번. cancelVideoProcessing(index) → 그 동영상만(또는 전부) 취소
|
|
2620
|
-
private final class MediaProcessingJob {
|
|
2621
|
-
private let lock = NSLock()
|
|
2622
|
-
private var cancelAll = false
|
|
2623
|
-
private var cancelled = Set<Int>()
|
|
2624
|
-
private var currentIndex: Int?
|
|
2625
|
-
private var currentExporter: VideoExporter?
|
|
2626
|
-
|
|
2627
|
-
func cancel(index: Int?) {
|
|
2628
|
-
lock.lock(); defer { lock.unlock() }
|
|
2629
|
-
if let i = index {
|
|
2630
|
-
cancelled.insert(i)
|
|
2631
|
-
if currentIndex == i { currentExporter?.cancel() }
|
|
2632
|
-
} else {
|
|
2633
|
-
cancelAll = true
|
|
2634
|
-
currentExporter?.cancel()
|
|
2635
|
-
}
|
|
2636
|
-
}
|
|
2637
|
-
|
|
2638
|
-
func isCancelled(_ i: Int) -> Bool {
|
|
2639
|
-
lock.lock(); defer { lock.unlock() }
|
|
2640
|
-
return cancelAll || cancelled.contains(i)
|
|
2641
|
-
}
|
|
2642
|
-
|
|
2643
|
-
func begin(index: Int, exporter: VideoExporter) {
|
|
2644
|
-
lock.lock(); defer { lock.unlock() }
|
|
2645
|
-
currentIndex = index
|
|
2646
|
-
currentExporter = exporter
|
|
2647
|
-
}
|
|
2648
|
-
|
|
2649
|
-
func end() {
|
|
2650
|
-
lock.lock(); defer { lock.unlock() }
|
|
2651
|
-
currentIndex = nil
|
|
2652
|
-
currentExporter = nil
|
|
2653
|
-
}
|
|
2654
|
-
}
|
|
2655
|
-
|
|
2656
|
-
/// onVideoProgress 를 1% 이상 바뀌고 0.1초 지났을 때만 (브릿지가 넘치지 않게). 0 과 1 은 항상
|
|
2657
|
-
private final class ProgressThrottle {
|
|
2658
|
-
private let lock = NSLock()
|
|
2659
|
-
private var last: Double = -1
|
|
2660
|
-
private var lastTime: CFTimeInterval = 0
|
|
2661
|
-
|
|
2662
|
-
func shouldSend(_ p: Double) -> Bool {
|
|
2663
|
-
lock.lock(); defer { lock.unlock() }
|
|
2664
|
-
let now = CACurrentMediaTime()
|
|
2665
|
-
guard p >= 1 || last < 0 || (p - last >= 0.01 && now - lastTime >= 0.1) else { return false }
|
|
2666
|
-
if p >= 1 && last >= 1 { return false }
|
|
2667
|
-
last = p
|
|
2668
|
-
lastTime = now
|
|
2669
|
-
return true
|
|
2670
|
-
}
|
|
2671
|
-
}
|
|
2672
|
-
|
|
2673
|
-
/// 스레드 사이에서 읽고 쓰는 Bool
|
|
2674
|
-
private final class AtomicFlag {
|
|
2675
|
-
private let lock = NSLock()
|
|
2676
|
-
private var v = false
|
|
2677
|
-
var value: Bool {
|
|
2678
|
-
get { lock.lock(); defer { lock.unlock() }; return v }
|
|
2679
|
-
set { lock.lock(); v = newValue; lock.unlock() }
|
|
2680
|
-
}
|
|
2681
|
-
}
|