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.
Files changed (30) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +4 -2
  3. package/android/build.gradle +8 -3
  4. package/android/src/main/libs/rnattachmentkit-release.aar +0 -0
  5. package/android/src/newarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +82 -341
  6. package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +82 -341
  7. package/ios/RNAttachmentKit.swift +84 -771
  8. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Assets.car +0 -0
  9. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.abi.json +6709 -449
  10. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +110 -0
  11. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  12. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftinterface +110 -0
  13. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  14. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  15. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeResources +12 -12
  16. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeSignature +0 -0
  17. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +6709 -449
  18. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +110 -0
  19. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  20. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftinterface +110 -0
  21. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +6709 -449
  22. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +110 -0
  23. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  24. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +110 -0
  25. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  26. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  27. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeResources +20 -20
  28. package/package.json +1 -1
  29. package/src/index.ts +4 -3
  30. 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: MediaProcessingJob] = [:]
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
- var results: [[String: Any]] = []
1037
- let tempDir = self.prepareCacheDirectory()
1038
- let maxWidth = galleryMaxWidth
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
- var currentExporter: VideoExporter?
1055
- var cancelledAll = false
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
- self.sendEvent(withName: "onProcessingStart", body: [
1074
- "processingId": processingId,
1075
- "total": total,
1076
- "items": startItems.enumerated().map { i, pair in
1077
- self.processingStartItem(index: i, asset: pair.0, image: pair.1, videoStates: videoStates,
1078
- videoOptions: videoOptions, maxWidth: maxWidth, maxHeight: maxHeight,
1079
- tempDir: tempDir)
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
- for index in 0..<total {
1083
- guard let pair = pending[index] else { continue }
1084
- pending[index] = nil
1085
- exporterLock.lock(); let stop = cancelledAll; exporterLock.unlock()
1086
- if stop { break }
1087
- autoreleasepool {
1088
- let (asset, image) = pair
1089
- let timestamp = Int(Date().timeIntervalSince1970 * 1000)
1090
- let originalSize = image.size
1091
-
1092
- if let phAsset = asset, phAsset.mediaType == .video {
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
- exporterLock.lock(); let wasCancelled = cancelledAll; exporterLock.unlock()
1278
- if wasCancelled {
1106
+ if aborted {
1279
1107
  // 변환 취소: 이미 만든 파일은 버림
1280
- results.compactMap { ($0["uri"] as? String)?.replacingOccurrences(of: "file://", with: "") }
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
- /// iOS가 저장 공간 부족 시 자동으로 정리하는 캐시 디렉토리
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 exporter = VideoExporter()
1842
- let hudCancelled = AtomicFlag()
1843
- hud?.onCancel = { hudCancelled.value = true; exporter.cancel() }
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 = Self.requestPoster(phAsset)
1295
+ let poster = AttachmentProcessor.requestPoster(phAsset)
1850
1296
  self.sendEvent(withName: "onProcessingStart", body: [
1851
1297
  "processingId": processingId,
1852
1298
  "total": 1,
1853
- "items": [self.processingStartItem(index: 0, asset: phAsset, image: poster, videoStates: [phAsset.localIdentifier: state],
1854
- videoOptions: videoOptions, maxWidth: 0, maxHeight: 0, tempDir: tempDir)],
1299
+ "items": [AttachmentProcessor.startItem(index: 0, asset: phAsset, image: poster, videoStates: [phAsset.localIdentifier: state],
1300
+ options: processingOptions).dictionary],
1855
1301
  ])
1856
- let timestamp = Int(Date().timeIntervalSince1970 * 1000)
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
- job.begin(index: 0, exporter: exporter)
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
- let r: VideoExportResult
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
- job.end()
1914
- self.sendEvent(withName: "onImageProcessed", body: ["index": 0, "total": 1, "image": result, "processingId": processingId])
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([result])
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 = self.saveImage(finalImage, format: format, prefix: "original", index: 0, timestamp: timestamp, tempDir: tempDir, forceJPEG: true)
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": self.mimeType(for: format),
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 = self.fileMetadata(fromPath: originalFilePath)
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 = self.resizeImage(image, maxWidth: maxWidth, maxHeight: maxHeight) ?? image
1414
+ let finalImageForFormat = AttachmentProcessor.resizeImage(image, maxWidth: maxWidth, maxHeight: maxHeight) ?? image
2013
1415
 
2014
- if let finalUri = self.saveImage(finalImageForFormat, format: format, prefix: "final", index: 0, timestamp: timestamp, tempDir: tempDir) {
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 = self.fileMetadata(fromPath: finalUri)
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(self.makePhotoThumbnail(finalImageForFormat, options: thumbOptions, index: 0,
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": self.mimeType(for: format)
1477
+ "type": AttachmentProcessor.mimeType(for: format)
2076
1478
  ]
2077
1479
 
2078
1480
  // ✅ 2순위: 에디터 다중 결과 원본 저장 (JPG 강제 사용으로 속도 최적화)
2079
- if let originalFilePath = self.saveImage(finalImage, format: format, prefix: "original", index: index, timestamp: timestamp, tempDir: tempDir, forceJPEG: true) {
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 = self.fileMetadata(fromPath: originalFilePath)
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) ? self.resizeImage(finalImage, maxWidth: maxWidth, maxHeight: maxHeight) : nil
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 = self.saveImage(resizedImage, format: format, prefix: "resized", index: index, timestamp: timestamp, tempDir: tempDir) {
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 = self.fileMetadata(fromPath: resizedUri)
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(self.makePhotoThumbnail(resizedForThumb ?? finalImage, options: thumbOptions, index: index,
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
- }