react-native-attachment-kit 1.0.17 → 1.1.0

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