react-native-attachment-kit 1.0.18 → 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.
- package/CHANGELOG.md +10 -0
- package/android/build.gradle +8 -3
- package/android/src/main/libs/rnattachmentkit-release.aar +0 -0
- package/android/src/newarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +58 -341
- package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +58 -341
- package/ios/RNAttachmentKit.swift +64 -771
- package/ios/RNAttachmentKit.xcframework/Info.plist +5 -5
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Assets.car +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.abi.json +6419 -422
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +107 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftinterface +107 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/RNAttachmentKit +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeResources +12 -12
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeSignature +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +6419 -422
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +107 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftinterface +107 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +6419 -422
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +107 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +107 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/RNAttachmentKit +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeResources +20 -20
- package/package.json +1 -1
|
@@ -51,7 +51,7 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
51
51
|
/// 열려 있는 미디어 갤러리 (galleryId → 화면)
|
|
52
52
|
fileprivate var mediaGalleries: [String: MediaGalleryViewController] = [:]
|
|
53
53
|
/// 진행 중인 전송 처리 (processingId → 작업, 메인 스레드에서만). 보내는 중에 갤러리를 다시 열어 또 보낼 수 있어 여러 개일 수 있음
|
|
54
|
-
private var processingJobs: [String:
|
|
54
|
+
private var processingJobs: [String: AttachmentProcessingJob] = [:]
|
|
55
55
|
/// 가장 최근에 시작한 처리 (cancelVideoProcessing 에 processingId 가 없을 때)
|
|
56
56
|
private var lastProcessingId: String?
|
|
57
57
|
/// 동영상 변환 진행 HUD 를 띄울지 — video.showProgressHUD, 없으면 asyncProcessing 이 아닐 때만
|
|
@@ -1033,255 +1033,62 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
1033
1033
|
return
|
|
1034
1034
|
}
|
|
1035
1035
|
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
let maxHeight = galleryMaxHeight
|
|
1040
|
-
let format = galleryFormat
|
|
1041
|
-
let mimeType = self.mimeType(for: format)
|
|
1042
|
-
let needResize = (maxWidth > 0 || maxHeight > 0)
|
|
1036
|
+
let processingOptions = AttachmentProcessingOptions(maxWidth: galleryMaxWidth, maxHeight: galleryMaxHeight,
|
|
1037
|
+
outputFormat: galleryFormat, thumbnail: galleryThumb,
|
|
1038
|
+
video: videoOptions)
|
|
1043
1039
|
let total = selectedImages.count
|
|
1044
1040
|
|
|
1045
|
-
let resultsLock = NSLock()
|
|
1046
|
-
// 처리한 이미지는 바로 놓아 주도록 (전체 UIImage 배열을 끝까지 들고 있지 않게) 옵셔널 배열로 옮김
|
|
1047
|
-
var pending: [(PHAsset?, UIImage)?] = selectedImages.map { $0 }
|
|
1048
|
-
|
|
1049
1041
|
// 동영상이 섞여 있으면 변환 진행 표시 (갤러리가 닫힌 뒤에도 보이게 키 윈도우에) + 취소
|
|
1050
1042
|
let hasVideo = selectedImages.contains { $0.0?.mediaType == .video }
|
|
1051
1043
|
// 편집 화면에서 고른 품질·음소거 (없으면 기본 품질)
|
|
1052
1044
|
let videoStates = galleryVC?.videoEditStates ?? [:]
|
|
1053
1045
|
var hud: VideoExportHUD?
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
let exporterLock = NSLock()
|
|
1057
|
-
// 앱이 동영상 하나(또는 전부)를 취소할 수 있게 (cancelVideoProcessing)
|
|
1058
|
-
let job = MediaProcessingJob()
|
|
1046
|
+
// 앱이 동영상 하나(또는 전부)를 취소할 수 있게 (cancelVideoProcessing). HUD 의 취소는 전체 중단(abort)
|
|
1047
|
+
let job = AttachmentProcessingJob()
|
|
1059
1048
|
let processingId = UUID().uuidString
|
|
1060
1049
|
self.processingJobs[processingId] = job
|
|
1061
1050
|
self.lastProcessingId = processingId
|
|
1062
|
-
// 처리 시작 이벤트용 — 고른 순서의 원본(사진은 편집 결과) 크기·포스터
|
|
1063
|
-
let startItems = selectedImages.map { ($0.0, $0.1) }
|
|
1064
1051
|
if hasVideo && self.galleryShowsVideoHUD {
|
|
1065
1052
|
hud = VideoExportHUD.show(accent: galleryAccent, languageCode: galleryLanguage)
|
|
1066
|
-
hud?.onCancel = {
|
|
1067
|
-
exporterLock.lock(); cancelledAll = true; currentExporter?.cancel(); exporterLock.unlock()
|
|
1068
|
-
}
|
|
1053
|
+
hud?.onCancel = { job.abort() }
|
|
1069
1054
|
}
|
|
1070
1055
|
|
|
1056
|
+
// 고르기·가공은 AttachmentProcessor (앱의 네이티브 화면과 같은 코드)
|
|
1071
1057
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|
+
])
|
|
1080
1067
|
},
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
// ✅ 동영상: VideoExporter 로 전송용 mp4 (+ 썸네일·메타). image 는 포스터(목록용)
|
|
1094
|
-
let exporter = VideoExporter()
|
|
1095
|
-
exporterLock.lock(); currentExporter = exporter; exporterLock.unlock()
|
|
1096
|
-
let base = "turbo_video_\(timestamp)_\(index)"
|
|
1097
|
-
var result: [String: Any] = [
|
|
1098
|
-
"originalUri": "ph://\(phAsset.localIdentifier)",
|
|
1099
|
-
"originalWidth": phAsset.pixelWidth,
|
|
1100
|
-
"originalHeight": phAsset.pixelHeight,
|
|
1101
|
-
"mediaType": "video",
|
|
1102
|
-
"type": "video/mp4",
|
|
1103
|
-
"duration": phAsset.duration,
|
|
1104
|
-
]
|
|
1105
|
-
do {
|
|
1106
|
-
// 앱이 이미 취소한 동영상은 건너뜀 (결과에 cancelled 로)
|
|
1107
|
-
if job.isCancelled(index) { throw VideoExporter.ExportError.cancelled }
|
|
1108
|
-
job.begin(index: index, exporter: exporter)
|
|
1109
|
-
let state = videoStates[phAsset.localIdentifier] ?? VideoEditState.defaultState(for: phAsset, options: videoOptions)
|
|
1110
|
-
let throttle = ProgressThrottle()
|
|
1111
|
-
let onProgress: (Double) -> Void = { p in
|
|
1112
|
-
// 프레임마다 메인으로 보내지 않고 1%·0.1초 단위로만 (HUD 표시는 정수 % 라 같음)
|
|
1113
|
-
guard throttle.shouldSend(p) else { return }
|
|
1114
|
-
DispatchQueue.main.async { hud?.update(progress: p, index: index, total: total) }
|
|
1115
|
-
self.sendEvent(withName: "onVideoProgress", body: ["index": index, "total": total, "progress": p, "processingId": processingId])
|
|
1116
|
-
}
|
|
1117
|
-
// 편집 화면에서 미리 만들어 둔 파일이 있으면(또는 만드는 중이면 끝나고) 그대로 씀
|
|
1118
|
-
let r: VideoExportResult
|
|
1119
|
-
// 기다리는 동안 앱(X)이나 HUD 가 취소하면 바로 멈춤 (전엔 미리 만들기가 끝날 때까지 못 빠져나왔음)
|
|
1120
|
-
let stop = { () -> Bool in
|
|
1121
|
-
exporterLock.lock(); let all = cancelledAll; exporterLock.unlock()
|
|
1122
|
-
return all || job.isCancelled(index)
|
|
1123
|
-
}
|
|
1124
|
-
if let ready = VideoPrepareManager.shared.takeReady(asset: phAsset, state: state, progress: onProgress, isCancelled: stop) {
|
|
1125
|
-
r = ready; onProgress(1)
|
|
1126
|
-
} else {
|
|
1127
|
-
if stop() { throw VideoExporter.ExportError.cancelled }
|
|
1128
|
-
r = try exporter.export(asset: phAsset, state: state,
|
|
1129
|
-
options: videoOptions, outputDir: URL(fileURLWithPath: tempDir), baseName: base, progress: onProgress)
|
|
1130
|
-
}
|
|
1131
|
-
// 미리 만든 파일을 기다리는 동안 취소됐으면 결과를 쓰지 않음
|
|
1132
|
-
if job.isCancelled(index) { throw VideoExporter.ExportError.cancelled }
|
|
1133
|
-
result["uri"] = "file://" + r.url.path
|
|
1134
|
-
result["width"] = r.width
|
|
1135
|
-
result["height"] = r.height
|
|
1136
|
-
result["duration"] = r.duration
|
|
1137
|
-
result["fileSize"] = r.fileSize
|
|
1138
|
-
result["fileName"] = r.url.lastPathComponent
|
|
1139
|
-
result["fileExtension"] = "mp4"
|
|
1140
|
-
result["videoCodec"] = r.codec
|
|
1141
|
-
result["videoBitrate"] = r.bitrate
|
|
1142
|
-
result["edited"] = !r.passthrough
|
|
1143
|
-
if let t = r.thumbnailURL {
|
|
1144
|
-
result["thumbnailUri"] = "file://" + t.path
|
|
1145
|
-
result["thumbnailWidth"] = r.thumbnailWidth
|
|
1146
|
-
result["thumbnailHeight"] = r.thumbnailHeight
|
|
1147
|
-
}
|
|
1148
|
-
} catch VideoExporter.ExportError.cancelled where job.isCancelled(index) {
|
|
1149
|
-
// 앱이 이 동영상만 취소 → 나머지는 계속
|
|
1150
|
-
result["error"] = "CANCELLED"
|
|
1151
|
-
result["cancelled"] = true
|
|
1152
|
-
} catch VideoExporter.ExportError.cancelled {
|
|
1153
|
-
// HUD 의 취소 → 전체 취소 (Promise reject)
|
|
1154
|
-
job.end()
|
|
1155
|
-
exporterLock.lock(); cancelledAll = true; exporterLock.unlock()
|
|
1156
|
-
return
|
|
1157
|
-
} catch {
|
|
1158
|
-
result["error"] = error.localizedDescription
|
|
1159
|
-
}
|
|
1160
|
-
job.end()
|
|
1161
|
-
exporterLock.lock(); currentExporter = nil; exporterLock.unlock()
|
|
1162
|
-
|
|
1163
|
-
resultsLock.lock()
|
|
1164
|
-
results.append(result)
|
|
1165
|
-
let currentResult = result
|
|
1166
|
-
resultsLock.unlock()
|
|
1167
|
-
self.sendEvent(withName: "onImageProcessed", body: ["index": index, "total": total, "image": currentResult, "processingId": processingId])
|
|
1168
|
-
} else if let phAsset = asset {
|
|
1169
|
-
// ✅ 1순위: PHAsset
|
|
1170
|
-
let originalUri = "ph://\(phAsset.localIdentifier)"
|
|
1171
|
-
|
|
1172
|
-
var targetImage: UIImage
|
|
1173
|
-
if needResize, let resized = self.resizeImage(image, maxWidth: maxWidth, maxHeight: maxHeight) {
|
|
1174
|
-
targetImage = resized
|
|
1175
|
-
} else {
|
|
1176
|
-
targetImage = image
|
|
1177
|
-
}
|
|
1178
|
-
targetImage = ImageProcessor.shared.applyWatermarkIfNeeded(targetImage)
|
|
1179
|
-
|
|
1180
|
-
var result: [String: Any] = [
|
|
1181
|
-
"originalUri": originalUri,
|
|
1182
|
-
"originalWidth": Int(originalSize.width),
|
|
1183
|
-
"originalHeight": Int(originalSize.height),
|
|
1184
|
-
"type": mimeType
|
|
1185
|
-
]
|
|
1186
|
-
|
|
1187
|
-
if let savedUri = self.saveImage(targetImage, format: format,
|
|
1188
|
-
prefix: "resized",
|
|
1189
|
-
index: index, timestamp: timestamp,
|
|
1190
|
-
tempDir: tempDir) {
|
|
1191
|
-
result["uri"] = savedUri
|
|
1192
|
-
result["width"] = Int(targetImage.size.width)
|
|
1193
|
-
result["height"] = Int(targetImage.size.height)
|
|
1194
|
-
let meta = self.fileMetadata(fromPath: savedUri)
|
|
1195
|
-
result["fileName"] = meta.fileName
|
|
1196
|
-
result["fileExtension"] = meta.fileExtension
|
|
1197
|
-
result["fileSize"] = meta.fileSize
|
|
1198
|
-
}
|
|
1199
|
-
// 썸네일: 같은 비트맵(방향·크롭·그리기 반영)에서
|
|
1200
|
-
result.merge(self.makePhotoThumbnail(targetImage, options: galleryThumb, index: index,
|
|
1201
|
-
timestamp: timestamp, tempDir: tempDir)) { $1 }
|
|
1202
|
-
|
|
1203
|
-
resultsLock.lock()
|
|
1204
|
-
results.append(result)
|
|
1205
|
-
let currentResult = result
|
|
1206
|
-
resultsLock.unlock()
|
|
1207
|
-
|
|
1208
|
-
self.sendEvent(withName: "onImageProcessed", body: [
|
|
1209
|
-
"index": index,
|
|
1210
|
-
"total": total,
|
|
1211
|
-
"image": currentResult,
|
|
1212
|
-
"processingId": processingId
|
|
1213
|
-
])
|
|
1214
|
-
} else {
|
|
1215
|
-
// ✅ 2순위: 카메라 촬영본
|
|
1216
|
-
guard let originalFilePath = self.saveOriginalImageAsJPEG(
|
|
1217
|
-
image, index: index, timestamp: timestamp, tempDir: tempDir) else {
|
|
1218
|
-
return
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
var targetImage: UIImage
|
|
1222
|
-
if needResize, let resized = self.resizeImage(image, maxWidth: maxWidth, maxHeight: maxHeight) {
|
|
1223
|
-
targetImage = resized
|
|
1224
|
-
} else {
|
|
1225
|
-
targetImage = image
|
|
1226
|
-
}
|
|
1227
|
-
targetImage = ImageProcessor.shared.applyWatermarkIfNeeded(targetImage)
|
|
1228
|
-
|
|
1229
|
-
var result: [String: Any] = [
|
|
1230
|
-
"originalUri": originalFilePath,
|
|
1231
|
-
"originalFileUri": originalFilePath,
|
|
1232
|
-
"originalWidth": Int(originalSize.width),
|
|
1233
|
-
"originalHeight": Int(originalSize.height),
|
|
1234
|
-
"type": mimeType
|
|
1235
|
-
]
|
|
1236
|
-
let origMeta = self.fileMetadata(fromPath: originalFilePath)
|
|
1237
|
-
result["fileName"] = origMeta.fileName
|
|
1238
|
-
result["fileExtension"] = origMeta.fileExtension
|
|
1239
|
-
result["fileSize"] = origMeta.fileSize
|
|
1240
|
-
|
|
1241
|
-
if let savedUri = self.saveImage(targetImage, format: format,
|
|
1242
|
-
prefix: "resized",
|
|
1243
|
-
index: index, timestamp: timestamp,
|
|
1244
|
-
tempDir: tempDir) {
|
|
1245
|
-
result["uri"] = savedUri
|
|
1246
|
-
result["width"] = Int(targetImage.size.width)
|
|
1247
|
-
result["height"] = Int(targetImage.size.height)
|
|
1248
|
-
let meta = self.fileMetadata(fromPath: savedUri)
|
|
1249
|
-
result["fileName"] = meta.fileName
|
|
1250
|
-
result["fileExtension"] = meta.fileExtension
|
|
1251
|
-
result["fileSize"] = meta.fileSize
|
|
1252
|
-
}
|
|
1253
|
-
// 썸네일: 같은 비트맵(방향·크롭·그리기 반영)에서
|
|
1254
|
-
result.merge(self.makePhotoThumbnail(targetImage, options: galleryThumb, index: index,
|
|
1255
|
-
timestamp: timestamp, tempDir: tempDir)) { $1 }
|
|
1256
|
-
|
|
1257
|
-
resultsLock.lock()
|
|
1258
|
-
results.append(result)
|
|
1259
|
-
let currentResult = result
|
|
1260
|
-
resultsLock.unlock()
|
|
1261
|
-
|
|
1262
|
-
self.sendEvent(withName: "onImageProcessed", body: [
|
|
1263
|
-
"index": index,
|
|
1264
|
-
"total": total,
|
|
1265
|
-
"image": currentResult,
|
|
1266
|
-
"processingId": processingId
|
|
1267
|
-
])
|
|
1268
|
-
}
|
|
1269
|
-
} // end autoreleasepool
|
|
1270
|
-
} // end for
|
|
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
|
+
})
|
|
1271
1080
|
|
|
1272
1081
|
DispatchQueue.main.async {
|
|
1273
1082
|
hud?.dismiss()
|
|
1274
1083
|
// 처리하는 동안 새 갤러리가 열렸을 수 있음 → 내 갤러리일 때만 비움
|
|
1275
1084
|
if self.currentGalleryVC == nil || self.currentGalleryVC === galleryVC { self.currentGalleryVC = nil }
|
|
1276
1085
|
self.processingJobs[processingId] = nil
|
|
1277
|
-
|
|
1278
|
-
if wasCancelled {
|
|
1086
|
+
if aborted {
|
|
1279
1087
|
// 변환 취소: 이미 만든 파일은 버림
|
|
1280
|
-
results.compactMap {
|
|
1281
|
-
.forEach { try? FileManager.default.removeItem(atPath: $0) }
|
|
1088
|
+
results.compactMap { $0.path }.forEach { try? FileManager.default.removeItem(atPath: $0) }
|
|
1282
1089
|
reject("CANCELLED", "User cancelled video conversion", nil)
|
|
1283
1090
|
} else {
|
|
1284
|
-
resolve(results)
|
|
1091
|
+
resolve(results.map { $0.dictionary })
|
|
1285
1092
|
}
|
|
1286
1093
|
// 결과를 넘긴 뒤: 갤러리의 PHCachingImageManager 캐시·콜백 해제 (닫힘 애니메이션에는 영향 없음).
|
|
1287
1094
|
// 새 갤러리가 열려 있으면 그 화면이 미리 인코딩 중인 파일은 건드리지 않음
|
|
@@ -1354,86 +1161,6 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
1354
1161
|
}
|
|
1355
1162
|
}
|
|
1356
1163
|
|
|
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
1164
|
@objc
|
|
1438
1165
|
func closeGallery(_ resolve: @escaping RCTPromiseResolveBlock,
|
|
1439
1166
|
reject: @escaping RCTPromiseRejectBlock) -> Void {
|
|
@@ -1470,309 +1197,8 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
1470
1197
|
|
|
1471
1198
|
// MARK: - Private Methods
|
|
1472
1199
|
|
|
1473
|
-
|
|
1474
|
-
/// picker 결과물은 일시적으로만 필요하므로 cache 사용
|
|
1475
|
-
private var cacheDirectory: String {
|
|
1476
|
-
return FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
|
|
1477
|
-
.first?.appendingPathComponent("RNAttachmentKit").path
|
|
1478
|
-
?? NSTemporaryDirectory()
|
|
1479
|
-
}
|
|
1200
|
+
// 사진 리사이즈·저장·썸네일·임시 폴더는 AttachmentProcessor (프레임워크 공개 API — 앱 네이티브 화면과 같은 코드)
|
|
1480
1201
|
|
|
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
|
|
1641
|
-
|
|
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
1202
|
/// 맨 위 컨트롤러에 present. 맨 위가 닫히거나 열리는 중이면(예: openGallery 가 끝난 직후 갤러리 시트가
|
|
1777
1203
|
/// 닫히는 애니메이션) UIKit 이 present 를 조용히 버리므로, 전환이 끝난 뒤 다시 시도합니다.
|
|
1778
1204
|
/// 끝내 present 되지 않으면 reject — 안 그러면 JS 약속이 안 풀리고 _editorOpen 가드에 계속 막힙니다.
|
|
@@ -1832,99 +1258,55 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
1832
1258
|
|
|
1833
1259
|
// MARK: - Editor Result Processing
|
|
1834
1260
|
|
|
1835
|
-
/// 동영상 1장을 전송용 mp4 로 만들어 resolve (갤러리 다중 전송 경로의 동영상 분기와 같은 필드)
|
|
1261
|
+
/// 동영상 1장을 전송용 mp4 로 만들어 resolve (갤러리 다중 전송 경로의 동영상 분기와 같은 필드 — AttachmentProcessor.processVideo)
|
|
1836
1262
|
private func exportSingleVideo(_ phAsset: PHAsset, galleryVC: GalleryViewController?, tempDir: String,
|
|
1837
1263
|
resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
1838
1264
|
let videoOptions = galleryVC?.videoExportOptions ?? VideoExportOptions()
|
|
1839
1265
|
let state = galleryVC?.videoEditStates[phAsset.localIdentifier] ?? VideoEditState.defaultState(for: phAsset, options: videoOptions)
|
|
1840
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
|
|
1841
|
-
let
|
|
1842
|
-
|
|
1843
|
-
hud?.onCancel = {
|
|
1844
|
-
let job = MediaProcessingJob()
|
|
1267
|
+
let job = AttachmentProcessingJob()
|
|
1268
|
+
// HUD 취소 = 전체 중단 (Promise reject)
|
|
1269
|
+
hud?.onCancel = { job.abort() }
|
|
1845
1270
|
let processingId = UUID().uuidString
|
|
1846
1271
|
processingJobs[processingId] = job
|
|
1847
1272
|
lastProcessingId = processingId
|
|
1273
|
+
let processingOptions = AttachmentProcessingOptions(video: videoOptions, outputDirectory: tempDir)
|
|
1848
1274
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
1849
|
-
let poster =
|
|
1275
|
+
let poster = AttachmentProcessor.requestPoster(phAsset)
|
|
1850
1276
|
self.sendEvent(withName: "onProcessingStart", body: [
|
|
1851
1277
|
"processingId": processingId,
|
|
1852
1278
|
"total": 1,
|
|
1853
|
-
"items": [
|
|
1854
|
-
|
|
1279
|
+
"items": [AttachmentProcessor.startItem(index: 0, asset: phAsset, image: poster, videoStates: [phAsset.localIdentifier: state],
|
|
1280
|
+
options: processingOptions).dictionary],
|
|
1855
1281
|
])
|
|
1856
|
-
let
|
|
1857
|
-
var result: [String: Any] = [
|
|
1858
|
-
"originalUri": "ph://\(phAsset.localIdentifier)",
|
|
1859
|
-
"originalWidth": phAsset.pixelWidth,
|
|
1860
|
-
"originalHeight": phAsset.pixelHeight,
|
|
1861
|
-
"mediaType": "video",
|
|
1862
|
-
"type": "video/mp4",
|
|
1863
|
-
"duration": phAsset.duration,
|
|
1864
|
-
]
|
|
1282
|
+
let result: AttachmentResult
|
|
1865
1283
|
do {
|
|
1866
|
-
|
|
1867
|
-
let throttle = ProgressThrottle()
|
|
1868
|
-
let onProgress: (Double) -> Void = { p in
|
|
1869
|
-
guard throttle.shouldSend(p) else { return }
|
|
1284
|
+
result = try AttachmentProcessor.processVideo(phAsset, index: 0, state: state, options: processingOptions, job: job, progress: { p in
|
|
1870
1285
|
DispatchQueue.main.async { hud?.update(progress: p, index: 0, total: 1) }
|
|
1871
1286
|
self.sendEvent(withName: "onVideoProgress", body: ["index": 0, "total": 1, "progress": p, "processingId": processingId])
|
|
1872
|
-
}
|
|
1873
|
-
|
|
1874
|
-
// HUD 취소는 exporter.cancel() — 기다리는 동안엔 job 으로 알 수 없으므로 HUD 취소도 job 에 기록
|
|
1875
|
-
if let ready = VideoPrepareManager.shared.takeReady(asset: phAsset, state: state, progress: onProgress,
|
|
1876
|
-
isCancelled: { job.isCancelled(0) || hudCancelled.value }) {
|
|
1877
|
-
r = ready; onProgress(1)
|
|
1878
|
-
} else {
|
|
1879
|
-
if job.isCancelled(0) || hudCancelled.value { throw VideoExporter.ExportError.cancelled }
|
|
1880
|
-
r = try exporter.export(asset: phAsset, state: state, options: videoOptions,
|
|
1881
|
-
outputDir: URL(fileURLWithPath: tempDir), baseName: "turbo_video_\(timestamp)_0", progress: onProgress)
|
|
1882
|
-
}
|
|
1883
|
-
result["uri"] = "file://" + r.url.path
|
|
1884
|
-
result["width"] = r.width
|
|
1885
|
-
result["height"] = r.height
|
|
1886
|
-
result["duration"] = r.duration
|
|
1887
|
-
result["fileSize"] = r.fileSize
|
|
1888
|
-
result["fileName"] = r.url.lastPathComponent
|
|
1889
|
-
result["fileExtension"] = "mp4"
|
|
1890
|
-
result["videoCodec"] = r.codec
|
|
1891
|
-
result["videoBitrate"] = r.bitrate
|
|
1892
|
-
result["edited"] = !r.passthrough
|
|
1893
|
-
if let t = r.thumbnailURL {
|
|
1894
|
-
result["thumbnailUri"] = "file://" + t.path
|
|
1895
|
-
result["thumbnailWidth"] = r.thumbnailWidth
|
|
1896
|
-
result["thumbnailHeight"] = r.thumbnailHeight
|
|
1897
|
-
}
|
|
1898
|
-
if job.isCancelled(0) { throw VideoExporter.ExportError.cancelled }
|
|
1899
|
-
} catch VideoExporter.ExportError.cancelled where job.isCancelled(0) {
|
|
1900
|
-
// 앱이 취소 (cancelVideoProcessing) — 이벤트·결과에 cancelled 로
|
|
1901
|
-
result["error"] = "CANCELLED"
|
|
1902
|
-
result["cancelled"] = true
|
|
1903
|
-
} catch VideoExporter.ExportError.cancelled {
|
|
1904
|
-
job.end()
|
|
1287
|
+
})
|
|
1288
|
+
} catch {
|
|
1905
1289
|
DispatchQueue.main.async {
|
|
1906
1290
|
self.processingJobs[processingId] = nil
|
|
1907
1291
|
hud?.dismiss(); reject("CANCELLED", "Video export cancelled", nil)
|
|
1908
1292
|
}
|
|
1909
1293
|
return
|
|
1910
|
-
} catch {
|
|
1911
|
-
result["error"] = error.localizedDescription
|
|
1912
1294
|
}
|
|
1913
|
-
|
|
1914
|
-
self.sendEvent(withName: "onImageProcessed", body: ["index": 0, "total": 1, "image":
|
|
1295
|
+
let dict = result.dictionary
|
|
1296
|
+
self.sendEvent(withName: "onImageProcessed", body: ["index": 0, "total": 1, "image": dict, "processingId": processingId])
|
|
1915
1297
|
DispatchQueue.main.async {
|
|
1916
1298
|
hud?.dismiss()
|
|
1917
1299
|
if self.currentGalleryVC == nil || self.currentGalleryVC === galleryVC { self.currentGalleryVC = nil }
|
|
1918
1300
|
self.processingJobs[processingId] = nil
|
|
1919
1301
|
galleryVC?.releaseAfterReturn(discardPreparedVideos: self.currentGalleryVC == nil)
|
|
1920
|
-
resolve([
|
|
1302
|
+
resolve([dict])
|
|
1921
1303
|
}
|
|
1922
1304
|
}
|
|
1923
1305
|
}
|
|
1924
1306
|
|
|
1925
1307
|
|
|
1926
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) {
|
|
1927
|
-
let tempDir = prepareCacheDirectory()
|
|
1309
|
+
let tempDir = AttachmentProcessor.prepareCacheDirectory()
|
|
1928
1310
|
// 동영상 1장 편집 → 전송: 편집 화면에서 고른 품질·음소거로 VideoExporter (사진 경로와 같은 결과 형태)
|
|
1929
1311
|
if let phAsset = asset, phAsset.mediaType == .video {
|
|
1930
1312
|
exportSingleVideo(phAsset, galleryVC: galleryVC, tempDir: tempDir, resolve: resolve, reject: reject)
|
|
@@ -1961,7 +1343,7 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
1961
1343
|
finalImage = ImageProcessor.shared.applyWatermarkIfNeeded(finalImage)
|
|
1962
1344
|
resultImage = finalImage
|
|
1963
1345
|
// ✅ 2순위: 에디터 결과 원본 저장 (WebP 소프트웨어 인코딩 방지를 위해 하드웨어 가속되는 JPG 강제 사용)
|
|
1964
|
-
originalFilePath =
|
|
1346
|
+
originalFilePath = AttachmentProcessor.saveImage(finalImage, format: format, prefix: "original", index: 0, timestamp: timestamp, tempDir: tempDir, forceJPEG: true)
|
|
1965
1347
|
dispatchGroup.leave()
|
|
1966
1348
|
}
|
|
1967
1349
|
|
|
@@ -1996,32 +1378,32 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
1996
1378
|
"originalUri": originalUri,
|
|
1997
1379
|
"originalWidth": Int(originalSize.width),
|
|
1998
1380
|
"originalHeight": Int(originalSize.height),
|
|
1999
|
-
"type":
|
|
1381
|
+
"type": AttachmentProcessor.mimeType(for: format),
|
|
2000
1382
|
"caption": caption
|
|
2001
1383
|
]
|
|
2002
1384
|
|
|
2003
1385
|
if let originalFilePath = originalFilePath {
|
|
2004
1386
|
result["originalFileUri"] = originalFilePath
|
|
2005
1387
|
result["uri"] = originalFilePath // 기본적으로 uri에도 파일 경로 저장
|
|
2006
|
-
let meta =
|
|
1388
|
+
let meta = AttachmentProcessor.fileMetadata(fromPath: originalFilePath)
|
|
2007
1389
|
result["fileName"] = meta.fileName
|
|
2008
1390
|
result["fileExtension"] = meta.fileExtension
|
|
2009
1391
|
result["fileSize"] = meta.fileSize
|
|
2010
1392
|
}
|
|
2011
1393
|
|
|
2012
|
-
let finalImageForFormat =
|
|
1394
|
+
let finalImageForFormat = AttachmentProcessor.resizeImage(image, maxWidth: maxWidth, maxHeight: maxHeight) ?? image
|
|
2013
1395
|
|
|
2014
|
-
if let finalUri =
|
|
1396
|
+
if let finalUri = AttachmentProcessor.saveImage(finalImageForFormat, format: format, prefix: "final", index: 0, timestamp: timestamp, tempDir: tempDir) {
|
|
2015
1397
|
result["uri"] = finalUri
|
|
2016
1398
|
result["width"] = Int(finalImageForFormat.size.width)
|
|
2017
1399
|
result["height"] = Int(finalImageForFormat.size.height)
|
|
2018
|
-
let finalMeta =
|
|
1400
|
+
let finalMeta = AttachmentProcessor.fileMetadata(fromPath: finalUri)
|
|
2019
1401
|
result["fileName"] = finalMeta.fileName
|
|
2020
1402
|
result["fileExtension"] = finalMeta.fileExtension
|
|
2021
1403
|
result["fileSize"] = finalMeta.fileSize
|
|
2022
1404
|
}
|
|
2023
1405
|
// 썸네일: 크롭·그리기·필터가 반영된 결과(uri 와 같은 비트맵)에서
|
|
2024
|
-
result.merge(
|
|
1406
|
+
result.merge(AttachmentProcessor.photoThumbnailFields(finalImageForFormat, options: thumbOptions, index: 0,
|
|
2025
1407
|
timestamp: timestamp, tempDir: tempDir)) { $1 }
|
|
2026
1408
|
|
|
2027
1409
|
DispatchQueue.main.async {
|
|
@@ -2034,7 +1416,7 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
2034
1416
|
|
|
2035
1417
|
|
|
2036
1418
|
private func processEditorResultMulti(assets: [PHAsset], croppedImages: [PHAsset: UIImage], filterStates: [PHAsset: FilterState], galleryVC: GalleryViewController, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
2037
|
-
let tempDir = prepareCacheDirectory()
|
|
1419
|
+
let tempDir = AttachmentProcessor.prepareCacheDirectory()
|
|
2038
1420
|
let maxWidth = galleryVC.maxWidth
|
|
2039
1421
|
let maxHeight = galleryVC.maxHeight
|
|
2040
1422
|
let format = galleryVC.outputFormat
|
|
@@ -2072,32 +1454,32 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
2072
1454
|
"originalUri": originalUri,
|
|
2073
1455
|
"originalWidth": Int(finalImage.size.width),
|
|
2074
1456
|
"originalHeight": Int(finalImage.size.height),
|
|
2075
|
-
"type":
|
|
1457
|
+
"type": AttachmentProcessor.mimeType(for: format)
|
|
2076
1458
|
]
|
|
2077
1459
|
|
|
2078
1460
|
// ✅ 2순위: 에디터 다중 결과 원본 저장 (JPG 강제 사용으로 속도 최적화)
|
|
2079
|
-
if let originalFilePath =
|
|
1461
|
+
if let originalFilePath = AttachmentProcessor.saveImage(finalImage, format: format, prefix: "original", index: index, timestamp: timestamp, tempDir: tempDir, forceJPEG: true) {
|
|
2080
1462
|
result["originalFileUri"] = originalFilePath
|
|
2081
1463
|
result["uri"] = originalFilePath // 기본적으로 uri에도 파일 경로 저장
|
|
2082
|
-
let meta =
|
|
1464
|
+
let meta = AttachmentProcessor.fileMetadata(fromPath: originalFilePath)
|
|
2083
1465
|
result["fileName"] = meta.fileName
|
|
2084
1466
|
result["fileExtension"] = meta.fileExtension
|
|
2085
1467
|
result["fileSize"] = meta.fileSize
|
|
2086
1468
|
}
|
|
2087
1469
|
|
|
2088
|
-
let resizedForThumb = (maxWidth > 0 || maxHeight > 0) ?
|
|
1470
|
+
let resizedForThumb = (maxWidth > 0 || maxHeight > 0) ? AttachmentProcessor.resizeImage(finalImage, maxWidth: maxWidth, maxHeight: maxHeight) : nil
|
|
2089
1471
|
if let resizedImage = resizedForThumb {
|
|
2090
|
-
if let resizedUri =
|
|
1472
|
+
if let resizedUri = AttachmentProcessor.saveImage(resizedImage, format: format, prefix: "resized", index: index, timestamp: timestamp, tempDir: tempDir) {
|
|
2091
1473
|
result["uri"] = resizedUri
|
|
2092
1474
|
result["width"] = Int(resizedImage.size.width)
|
|
2093
1475
|
result["height"] = Int(resizedImage.size.height)
|
|
2094
|
-
let resizedMeta =
|
|
1476
|
+
let resizedMeta = AttachmentProcessor.fileMetadata(fromPath: resizedUri)
|
|
2095
1477
|
result["fileName"] = resizedMeta.fileName
|
|
2096
1478
|
result["fileExtension"] = resizedMeta.fileExtension
|
|
2097
1479
|
result["fileSize"] = resizedMeta.fileSize
|
|
2098
1480
|
}
|
|
2099
1481
|
}
|
|
2100
|
-
result.merge(
|
|
1482
|
+
result.merge(AttachmentProcessor.photoThumbnailFields(resizedForThumb ?? finalImage, options: thumbOptions, index: index,
|
|
2101
1483
|
timestamp: timestamp, tempDir: tempDir)) { $1 }
|
|
2102
1484
|
|
|
2103
1485
|
resultsLock.lock()
|
|
@@ -2144,29 +1526,6 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
2144
1526
|
}
|
|
2145
1527
|
}
|
|
2146
1528
|
|
|
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
1529
|
// MARK: - UIColor Extension for Hex Support
|
|
2171
1530
|
extension UIColor {
|
|
2172
1531
|
convenience init?(hexString: String) {
|
|
@@ -2613,69 +1972,3 @@ extension RNAttachmentKit: UIViewControllerTransitioningDelegate {
|
|
|
2613
1972
|
return zoomAnimator
|
|
2614
1973
|
}
|
|
2615
1974
|
}
|
|
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
|
-
}
|