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.
- package/CHANGELOG.md +472 -22
- 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 +60 -341
- package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +60 -341
- package/ios/RNAttachmentKit.swift +66 -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 +6853 -669
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +108 -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 +108 -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/Assets.car +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +6853 -669
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +108 -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 +108 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +6853 -669
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +108 -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 +108 -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 +22 -22
- package/package.json +1 -1
- 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:
|
|
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
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
-
|
|
1053
|
-
|
|
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
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
-
|
|
1276
|
-
if wasCancelled {
|
|
1086
|
+
if aborted {
|
|
1277
1087
|
// 변환 취소: 이미 만든 파일은 버림
|
|
1278
|
-
results.compactMap {
|
|
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
|
-
|
|
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
|
|
1840
|
-
|
|
1841
|
-
hud?.onCancel = {
|
|
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 =
|
|
1275
|
+
let poster = AttachmentProcessor.requestPoster(phAsset)
|
|
1848
1276
|
self.sendEvent(withName: "onProcessingStart", body: [
|
|
1849
1277
|
"processingId": processingId,
|
|
1850
1278
|
"total": 1,
|
|
1851
|
-
"items": [
|
|
1852
|
-
|
|
1279
|
+
"items": [AttachmentProcessor.startItem(index: 0, asset: phAsset, image: poster, videoStates: [phAsset.localIdentifier: state],
|
|
1280
|
+
options: processingOptions).dictionary],
|
|
1853
1281
|
])
|
|
1854
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1912
|
-
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])
|
|
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([
|
|
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 =
|
|
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":
|
|
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 =
|
|
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 =
|
|
1394
|
+
let finalImageForFormat = AttachmentProcessor.resizeImage(image, maxWidth: maxWidth, maxHeight: maxHeight) ?? image
|
|
2011
1395
|
|
|
2012
|
-
if let finalUri =
|
|
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 =
|
|
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(
|
|
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":
|
|
1457
|
+
"type": AttachmentProcessor.mimeType(for: format)
|
|
2074
1458
|
]
|
|
2075
1459
|
|
|
2076
1460
|
// ✅ 2순위: 에디터 다중 결과 원본 저장 (JPG 강제 사용으로 속도 최적화)
|
|
2077
|
-
if let originalFilePath =
|
|
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 =
|
|
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) ?
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
-
}
|