react-native-attachment-kit 1.0.18 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +4 -2
  3. package/android/build.gradle +8 -3
  4. package/android/src/main/libs/rnattachmentkit-release.aar +0 -0
  5. package/android/src/newarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +82 -341
  6. package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +82 -341
  7. package/ios/RNAttachmentKit.swift +84 -771
  8. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Assets.car +0 -0
  9. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.abi.json +6709 -449
  10. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +110 -0
  11. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  12. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftinterface +110 -0
  13. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  14. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  15. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeResources +12 -12
  16. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeSignature +0 -0
  17. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +6709 -449
  18. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +110 -0
  19. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  20. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftinterface +110 -0
  21. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +6709 -449
  22. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +110 -0
  23. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  24. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +110 -0
  25. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  26. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  27. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeResources +20 -20
  28. package/package.json +1 -1
  29. package/src/index.ts +4 -3
  30. package/src/types.ts +28 -0
@@ -46,8 +46,6 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
46
46
  private var lastMaxSelection: Int = 10
47
47
  private var lastThemeColorInt: Int? = null
48
48
  private var lastLanguageCode: String = "en"
49
- @Volatile private var currentVideoExporter: com.rnattachmentkit.VideoExporter? = null
50
- @Volatile private var videoCancelled = false
51
49
  /** 진행 중인 전송 처리 (processingId → 작업). 보내는 중에 갤러리를 다시 열어 또 보낼 수 있어 여러 개일 수 있음 */
52
50
  private val activeProcessingJobs = java.util.concurrent.ConcurrentHashMap<String, com.rnattachmentkit.ProcessingJob>()
53
51
  /** 가장 최근에 시작한 처리 (cancelVideoProcessing 에 processingId 가 없을 때) */
@@ -524,10 +522,31 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
524
522
  intent.putStringArrayListExtra("sourceBorderCorners", corners)
525
523
  }
526
524
  }
525
+ // 잘라내기 영역 — 넘긴 키만 바뀜 (useOverlayViewer 뷰어만 반영)
526
+ clipArrayOf(options, "sourceClipRect")?.let { intent.putExtra("sourceClipRect", it) }
527
+ clipArrayOf(options, "sourceClipInsets")?.let { intent.putExtra("sourceClipInsets", it) }
527
528
  androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(reactApplicationContext).sendBroadcast(intent)
528
529
  promise.resolve(null)
529
530
  }
530
531
 
532
+ /**
533
+ * sourceClipRect {x,y,width,height} / sourceClipInsets {top,bottom,left,right} (dp) → 화면 px [l,t,r,b]
534
+ * (여백이면 각 변의 여백). 키가 없으면 null(안 바꿈), 값이 null 이면 빈 배열(자르지 않음).
535
+ */
536
+ private fun clipArrayOf(options: ReadableMap, key: String): FloatArray? {
537
+ if (!options.hasKey(key)) return null
538
+ if (options.isNull(key)) return FloatArray(0)
539
+ val m = options.getMap(key) ?: return FloatArray(0)
540
+ fun px(k: String) = if (m.hasKey(k) && !m.isNull(k)) com.facebook.react.uimanager.PixelUtil.toPixelFromDIP(m.getDouble(k).toFloat()) else 0f
541
+ return if (key == "sourceClipRect") {
542
+ val x = px("x")
543
+ val y = px("y")
544
+ floatArrayOf(x, y, x + px("width"), y + px("height"))
545
+ } else {
546
+ floatArrayOf(px("left"), px("top"), px("right"), px("bottom"))
547
+ }
548
+ }
549
+
531
550
  /** 동영상 변환 취소 (HUD 없이 앱이 말풍선에서). index -1 = 남은 동영상 전부. 진행 중인 처리가 없으면 false */
532
551
  @ReactMethod
533
552
  fun cancelVideoProcessing(index: Double, processingId: String, promise: Promise) {
@@ -675,6 +694,9 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
675
694
  if (options.hasKey("sourceBackgroundColor")) {
676
695
  putString("sourceBackgroundColor", options.getString("sourceBackgroundColor"))
677
696
  }
697
+ // 잘라내기 영역 (열림·닫힘 중 사진과 가림 판이 그려지는 곳) — 이 오버레이 뷰어만
698
+ clipArrayOf(options, "sourceClipRect")?.let { putFloatArray("sourceClipRect", it) }
699
+ clipArrayOf(options, "sourceClipInsets")?.let { putFloatArray("sourceClipInsets", it) }
678
700
  // placeholderImages: 타입/뷰어 코드에는 있었지만 브릿지에서 한 번도 전달되지 않았던
679
701
  // 필드. 네이티브 뷰어가 onlyRetrieveFromCache(true)로 첫 프레임을 즉시 그리려 할 때
680
702
  // 쓰는 저해상도 대체 이미지 목록입니다(원본 다운로드 전 임시 표시용).
@@ -969,374 +991,93 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
969
991
  }
970
992
  }
971
993
 
994
+ /** 고른 뒤 처리 — com.rnattachmentkit.AttachmentProcessor (앱 네이티브 화면과 같은 코드). 여기선 RN 이벤트·HUD 만 */
972
995
  private suspend fun processImages(uris: List<Uri>, snap: SendSnapshot): List<com.facebook.react.bridge.WritableMap> = withContext(Dispatchers.IO) {
973
- // 보낸 순간의 설정 (모듈 필드와 같은 이름으로 가림 — 아래 코드는 그대로)
974
- val maxWidth = snap.maxWidth
975
- val maxHeight = snap.maxHeight
976
- val outputFormat = snap.outputFormat
977
- val videoOptions = snap.videoOptions
978
- val lastVideoEditStates = snap.states
979
- val showVideoHUD = snap.showHUD
980
- val lastThemeColorInt = snap.themeColor
981
- val lastLanguageCode = snap.language
982
- val thumbnailOptions = snap.thumbnail
983
996
  val processingId = java.util.UUID.randomUUID().toString()
984
- val results = mutableListOf<com.facebook.react.bridge.WritableMap>()
985
- val contentResolver = reactApplicationContext.contentResolver
986
997
  val totalCount = uris.size
987
- var currentIndex = 0
988
-
989
- // 동영상이 섞여 있으면 진행 HUD 를 띄우고, 동영상은 VideoExporter 로 (사진 처리와 같은 루프)
990
- val isVideoUri: (Uri) -> Boolean = { u ->
991
- u.toString().contains("/video/") || (try { contentResolver.getType(u)?.startsWith("video/") == true } catch (_: Exception) { false })
992
- }
993
- val hasVideo = uris.any(isVideoUri)
994
- videoCancelled = false
998
+ val options = com.rnattachmentkit.AttachmentProcessor.Options(
999
+ maxWidth = snap.maxWidth, maxHeight = snap.maxHeight, outputFormat = snap.outputFormat,
1000
+ thumbnail = snap.thumbnail, videoOptions = snap.videoOptions,
1001
+ )
1002
+ val hasVideo = uris.any { com.rnattachmentkit.AttachmentProcessor.isVideoUri(reactApplicationContext, it) }
1003
+ // 앱의 cancelVideoProcessing(index) → job.cancel, HUD 의 취소 → job.abort (전체 취소)
995
1004
  val job = com.rnattachmentkit.ProcessingJob()
996
1005
  activeProcessingJobs[processingId] = job
997
1006
  lastProcessingId = processingId
998
- // ✅ 처리 시작: 모든 자리(사진·동영상)를 먼저 알림 → 앱이 말풍선을 바로 만들고 진행률을 그림
999
- try {
1000
- val items = Arguments.createArray()
1001
- uris.forEachIndexed { i, u ->
1002
- val it0 = if (isVideoUri(u)) {
1003
- val st = lastVideoEditStates[u.toString()] ?: com.rnattachmentkit.VideoEditState(qualityHeight = videoOptions.defaultQuality)
1004
- com.rnattachmentkit.MediaProcessing.videoItem(reactApplicationContext, i, u, st, videoOptions)
1005
- } else {
1006
- com.rnattachmentkit.MediaProcessing.imageItem(reactApplicationContext, i, u, maxWidth, maxHeight)
1007
- }
1008
- items.pushMap(Arguments.createMap().apply {
1009
- putInt("index", it0.index)
1010
- putString("mediaType", if (it0.isVideo) "video" else "image")
1011
- putString("originalUri", it0.originalUri)
1012
- putInt("width", it0.width)
1013
- putInt("height", it0.height)
1014
- it0.durationSec?.let { d -> putDouble("duration", d) }
1015
- it0.thumbnailUri?.let { t ->
1016
- putString("thumbnailUri", t)
1017
- putInt("thumbnailWidth", it0.thumbnailWidth)
1018
- putInt("thumbnailHeight", it0.thumbnailHeight)
1019
- }
1020
- })
1021
- }
1022
- emitGalleryEvent("onProcessingStart", Arguments.createMap().apply {
1023
- putString("processingId", processingId)
1024
- putInt("total", totalCount)
1025
- putArray("items", items)
1026
- })
1027
- } catch (e: Exception) {
1028
- android.util.Log.w("RNAttachmentKit", "onProcessingStart failed: ${e.message}")
1029
- }
1030
1007
  var hud: com.rnattachmentkit.VideoExportHUD? = null
1031
- if (hasVideo && showVideoHUD) {
1008
+ if (hasVideo && snap.showHUD) {
1032
1009
  withContext(Dispatchers.Main) {
1033
1010
  val act = getCurrentActivity() ?: return@withContext
1034
- hud = com.rnattachmentkit.VideoExportHUD(act, lastThemeColorInt, lastLanguageCode).also { h ->
1035
- h.onCancel = {
1036
- videoCancelled = true
1037
- currentVideoExporter?.cancel()
1038
- }
1011
+ hud = com.rnattachmentkit.VideoExportHUD(act, snap.themeColor, snap.language).also { h ->
1012
+ h.onCancel = { job.abort() }
1039
1013
  h.show()
1040
1014
  }
1041
1015
  }
1042
1016
  }
1043
- val videoFiles = mutableListOf<File>()
1044
-
1017
+ val main = android.os.Handler(android.os.Looper.getMainLooper())
1018
+ val emitter = { reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) }
1045
1019
  try {
1046
- for (uri in uris) {
1047
- if (videoCancelled) throw com.rnattachmentkit.VideoExporter.CancelledException()
1048
- if (isVideoUri(uri)) {
1049
- val idx = currentIndex
1050
- val exporter = com.rnattachmentkit.VideoExporter(reactApplicationContext)
1051
- currentVideoExporter = exporter
1052
- val cancelledMap = {
1053
- WritableNativeMap().apply {
1054
- putString("originalUri", uri.toString())
1055
- putString("mediaType", "video")
1056
- putString("error", "CANCELLED")
1057
- putBoolean("cancelled", true)
1058
- }
1059
- }
1060
- val resultMap = try {
1061
- // 앱이 이미 취소한 동영상은 건너뜀
1062
- if (job.isCancelled(idx)) throw com.rnattachmentkit.VideoExporter.CancelledException()
1063
- job.begin(idx, exporter)
1064
- val state = lastVideoEditStates[uri.toString()] ?: com.rnattachmentkit.VideoEditState(qualityHeight = videoOptions.defaultQuality)
1065
- val throttle = com.rnattachmentkit.ProgressThrottle()
1066
- val onProgress: (Float) -> Unit = { p ->
1067
- hud?.let { h -> android.os.Handler(android.os.Looper.getMainLooper()).post { h.update(p, idx, totalCount) } }
1068
- if (throttle.shouldSend(p)) emitGalleryEvent("onVideoProgress", Arguments.createMap().apply {
1069
- putInt("index", idx); putInt("total", totalCount); putDouble("progress", p.toDouble())
1020
+ com.rnattachmentkit.AttachmentProcessor.process(reactApplicationContext, uris, snap.states, options, job,
1021
+ object : com.rnattachmentkit.AttachmentProcessor.Listener {
1022
+ // ✅ 처리 시작: 모든 자리(사진·동영상)를 먼저 알림 → 앱이 말풍선을 바로 만들고 진행률을 그림
1023
+ override fun onStart(items: List<com.rnattachmentkit.MediaProcessing.StartItem>) {
1024
+ val arr = Arguments.createArray()
1025
+ items.forEach { it0 ->
1026
+ arr.pushMap(Arguments.createMap().apply {
1027
+ putInt("index", it0.index)
1028
+ putString("mediaType", if (it0.isVideo) "video" else "image")
1029
+ putString("originalUri", it0.originalUri)
1030
+ putInt("width", it0.width)
1031
+ putInt("height", it0.height)
1032
+ it0.durationSec?.let { d -> putDouble("duration", d) }
1033
+ it0.thumbnailUri?.let { t ->
1034
+ putString("thumbnailUri", t)
1035
+ putInt("thumbnailWidth", it0.thumbnailWidth)
1036
+ putInt("thumbnailHeight", it0.thumbnailHeight)
1037
+ }
1038
+ })
1039
+ }
1040
+ emitGalleryEvent("onProcessingStart", Arguments.createMap().apply {
1070
1041
  putString("processingId", processingId)
1042
+ putInt("total", totalCount)
1043
+ putArray("items", arr)
1071
1044
  })
1072
1045
  }
1073
- // 편집 화면에서 미리 만들어 둔 파일이 있으면(또는 만드는 중이면 끝나고) 그대로 씀
1074
- // 기다리는 동안 앱(X)이나 HUD 가 취소하면 바로 멈춤 (전엔 미리 만들기가 끝날 때까지 못 빠져나왔음)
1075
- val stop = { videoCancelled || job.isCancelled(idx) }
1076
- val res = com.rnattachmentkit.VideoPrepareManager.takeReady(uri, state, onProgress, stop)
1077
- ?: run {
1078
- if (stop()) throw com.rnattachmentkit.VideoExporter.CancelledException()
1079
- exporter.export(uri, state, videoOptions, reactApplicationContext.cacheDir, "turbo_video_${System.currentTimeMillis()}_$idx", onProgress)
1080
- }
1081
- withContext(Dispatchers.Main) { hud?.update(1f, idx, totalCount) }
1082
- // 미리 만든 파일을 기다리는 동안 취소됐으면 결과를 쓰지 않음
1083
- if (job.isCancelled(idx)) throw com.rnattachmentkit.VideoExporter.CancelledException()
1084
- videoFiles += res.file
1085
- res.thumbnailFile?.let { videoFiles += it }
1086
- WritableNativeMap().apply {
1087
- putString("uri", Uri.fromFile(res.file).toString())
1088
- putString("originalUri", uri.toString())
1089
- putString("mediaType", "video")
1090
- putString("type", "video/mp4")
1091
- putInt("width", res.width)
1092
- putInt("height", res.height)
1093
- putDouble("duration", res.durationMs / 1000.0)
1094
- putDouble("fileSize", res.fileSize.toDouble())
1095
- putString("fileName", res.file.name)
1096
- putString("fileExtension", "mp4")
1097
- putString("videoCodec", res.codec)
1098
- putInt("videoBitrate", res.bitrate)
1099
- putBoolean("edited", res.edited)
1100
- if (res.thumbnailFile != null) {
1101
- putString("thumbnailUri", Uri.fromFile(res.thumbnailFile).toString())
1102
- putInt("thumbnailWidth", res.thumbnailWidth)
1103
- putInt("thumbnailHeight", res.thumbnailHeight)
1104
- }
1105
- }
1106
- } catch (e: com.rnattachmentkit.VideoExporter.CancelledException) {
1107
- // 앱이 이 동영상만 취소 → 나머지는 계속. HUD 취소면 전체 취소
1108
- if (job.isCancelled(idx) && !videoCancelled) cancelledMap() else throw e
1109
- } catch (e: Exception) {
1110
- // 취소하면서 변환기가 다른 예외로 끝날 수도 있음 → 취소로 처리
1111
- if (job.isCancelled(idx) && !videoCancelled) cancelledMap() else {
1112
- android.util.Log.w("RNAttachmentKit", "video export failed: ${e.message}", e)
1113
- WritableNativeMap().apply {
1114
- putString("originalUri", uri.toString())
1115
- putString("mediaType", "video")
1116
- putString("error", e.message ?: "export failed")
1117
- }
1118
- }
1119
- } finally {
1120
- currentVideoExporter = null
1121
- job.end()
1122
- }
1123
- results.add(resultMap)
1124
- try {
1125
- val copy = WritableNativeMap().apply { merge(resultMap) }
1126
- reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1127
- .emit("onImageProcessed", WritableNativeMap().apply {
1128
- putInt("total", totalCount); putInt("index", idx); putMap("image", copy)
1046
+
1047
+ override fun onVideoProgress(index: Int, progress: Float) {
1048
+ hud?.let { h -> main.post { h.update(progress, index, totalCount) } }
1049
+ emitGalleryEvent("onVideoProgress", Arguments.createMap().apply {
1050
+ putInt("index", index); putInt("total", totalCount); putDouble("progress", progress.toDouble())
1129
1051
  putString("processingId", processingId)
1130
1052
  })
1131
- } catch (_: Exception) {}
1132
- currentIndex++
1133
- continue
1134
- }
1135
- try {
1136
- // 1. Get EXIF Orientation
1137
- var orientation = android.media.ExifInterface.ORIENTATION_NORMAL
1138
- try {
1139
- contentResolver.openInputStream(uri)?.use { exifInputStream ->
1140
- val exif = android.media.ExifInterface(exifInputStream)
1141
- orientation = exif.getAttributeInt(android.media.ExifInterface.TAG_ORIENTATION, android.media.ExifInterface.ORIENTATION_NORMAL)
1142
1053
  }
1143
- } catch (e: Exception) {}
1144
1054
 
1145
- contentResolver.openInputStream(uri)?.use { inputStream ->
1146
- // Get original image dimensions
1147
- val originalOptions = BitmapFactory.Options().apply {
1148
- inJustDecodeBounds = true
1149
- }
1150
- BitmapFactory.decodeStream(inputStream, null, originalOptions)
1151
-
1152
- var originalWidth = originalOptions.outWidth
1153
- var originalHeight = originalOptions.outHeight
1154
-
1155
- // Swap width and height if rotated by 90 or 270 degrees
1156
- if (orientation == android.media.ExifInterface.ORIENTATION_ROTATE_90 || orientation == android.media.ExifInterface.ORIENTATION_ROTATE_270) {
1157
- val temp = originalWidth
1158
- originalWidth = originalHeight
1159
- originalHeight = temp
1160
- }
1161
-
1162
- // Load and resize image
1163
- contentResolver.openInputStream(uri)?.use { resizedInputStream ->
1164
- // Initial decode for sizing
1165
- val decodeOptions = BitmapFactory.Options().apply {
1166
- inSampleSize = calculateInSampleSize(originalWidth, originalHeight, maxWidth, maxHeight)
1167
- }
1168
- var bitmap = BitmapFactory.decodeStream(resizedInputStream, null, decodeOptions)
1169
-
1170
- if (bitmap != null) {
1171
- // Apply EXIF Rotation
1172
- val matrix = android.graphics.Matrix()
1173
- when (orientation) {
1174
- android.media.ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
1175
- android.media.ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
1176
- android.media.ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
1177
- android.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
1178
- android.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
1179
- }
1180
-
1181
- if (!matrix.isIdentity) {
1182
- val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
1183
- if (rotatedBitmap != bitmap) {
1184
- bitmap.recycle()
1185
- bitmap = rotatedBitmap
1186
- }
1187
- }
1188
-
1189
- // Precise resize if needed
1190
- val finalBitmap = if (bitmap.width > maxWidth || bitmap.height > maxHeight) {
1191
- resizeBitmap(bitmap, maxWidth, maxHeight)
1192
- } else {
1193
- bitmap
1194
- }
1195
-
1196
- val processedBitmap = ImageProcessor.applyWatermarkIfNeeded(reactApplicationContext, finalBitmap)
1197
-
1198
- // Determine format and extension
1199
- val compressFormat: Bitmap.CompressFormat
1200
- val extension: String
1201
- val mimeType: String
1202
-
1203
- when (outputFormat) {
1204
- "png" -> {
1205
- compressFormat = Bitmap.CompressFormat.PNG
1206
- extension = "png"
1207
- mimeType = "image/png"
1208
- }
1209
- "jpg", "jpeg" -> {
1210
- compressFormat = Bitmap.CompressFormat.JPEG
1211
- extension = "jpg"
1212
- mimeType = "image/jpeg"
1213
- }
1214
- else -> { // webp
1215
- compressFormat = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
1216
- Bitmap.CompressFormat.WEBP_LOSSY
1217
- } else {
1218
- @Suppress("DEPRECATION")
1219
- Bitmap.CompressFormat.WEBP
1220
- }
1221
- extension = "webp"
1222
- mimeType = "image/webp"
1223
- }
1224
- }
1225
-
1226
- // Save to temporary file
1227
- val tempFile = File.createTempFile("turbo_edited_${System.currentTimeMillis()}_", ".$extension", reactApplicationContext.cacheDir)
1228
- FileOutputStream(tempFile).use { out ->
1229
- processedBitmap.compress(compressFormat, 85, out)
1230
- }
1231
- // 썸네일: 같은 비트맵(방향·크롭·그리기 반영)에서 — 원본을 다시 읽지 않음
1232
- val thumb = thumbnailOptions?.let {
1233
- com.rnattachmentkit.PhotoThumbnail.make(processedBitmap, it, reactApplicationContext.cacheDir, currentIndex)
1234
- }
1235
-
1236
- val finalWidth = processedBitmap.width
1237
- val finalHeight = processedBitmap.height
1238
-
1239
- // Cleanup
1240
- if (bitmap != processedBitmap) {
1241
- bitmap.recycle()
1242
- }
1243
- if (finalBitmap != processedBitmap && finalBitmap != bitmap) {
1244
- finalBitmap.recycle()
1245
- }
1246
- processedBitmap.recycle()
1247
-
1248
- val createImageInfo = {
1249
- WritableNativeMap().apply {
1250
- putString("uri", Uri.fromFile(tempFile).toString())
1251
- putInt("width", finalWidth)
1252
- putInt("height", finalHeight)
1253
- putString("type", mimeType)
1254
- putString("fileName", tempFile.name)
1255
- putString("fileExtension", extension)
1256
- putDouble("fileSize", tempFile.length().toDouble())
1257
- putString("originalUri", uri.toString())
1258
- putInt("originalWidth", originalWidth)
1259
- putInt("originalHeight", originalHeight)
1260
- if (thumb != null) {
1261
- putString("thumbnailUri", Uri.fromFile(thumb.file).toString())
1262
- putInt("thumbnailWidth", thumb.width)
1263
- putInt("thumbnailHeight", thumb.height)
1264
- putDouble("thumbnailFileSize", thumb.fileSize.toDouble())
1265
- }
1266
- }
1267
- }
1268
- val imageInfo = createImageInfo()
1269
- results.add(imageInfo)
1270
-
1271
- try {
1272
- val eventMap = WritableNativeMap().apply {
1273
- putInt("total", totalCount)
1274
- putInt("index", currentIndex)
1275
- putString("processingId", processingId)
1276
- putMap("image", createImageInfo())
1277
- }
1278
- reactApplicationContext.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1279
- .emit("onImageProcessed", eventMap)
1280
- } catch (e: Exception) { }
1281
- }
1055
+ override fun onItem(result: com.rnattachmentkit.AttachmentProcessor.Result) {
1056
+ if (result.isVideo) hud?.let { h -> main.post { h.update(1f, result.index, totalCount) } }
1057
+ try {
1058
+ emitter().emit("onImageProcessed", WritableNativeMap().apply {
1059
+ putInt("total", totalCount); putInt("index", result.index); putMap("image", toWritableMap(result))
1060
+ putString("processingId", processingId)
1061
+ })
1062
+ } catch (_: Exception) {}
1282
1063
  }
1283
- }
1284
- currentIndex++
1285
- } catch (e: Exception) {
1286
- currentIndex++
1287
- // Skip failed images
1288
- continue
1289
- }
1290
- }
1291
- } catch (e: com.rnattachmentkit.VideoExporter.CancelledException) {
1292
- // 취소: 지금까지 만든 동영상 파일은 지움
1293
- videoFiles.forEach { try { it.delete() } catch (_: Exception) {} }
1294
- throw e
1064
+ }).map { toWritableMap(it) }
1295
1065
  } finally {
1296
1066
  activeProcessingJobs.remove(processingId)
1297
1067
  if (hud != null) withContext(Dispatchers.Main) { hud?.dismiss() }
1298
1068
  }
1299
-
1300
- results
1301
1069
  }
1302
-
1303
- private fun calculateInSampleSize(
1304
- reqWidth: Int,
1305
- reqHeight: Int,
1306
- maxWidth: Int,
1307
- maxHeight: Int
1308
- ): Int {
1309
- var inSampleSize = 1
1310
-
1311
- if (reqHeight > maxHeight || reqWidth > maxWidth) {
1312
- val halfHeight = reqHeight / 2
1313
- val halfWidth = reqWidth / 2
1314
-
1315
- while ((halfHeight / inSampleSize) >= maxHeight && (halfWidth / inSampleSize) >= maxWidth) {
1316
- inSampleSize *= 2
1070
+
1071
+ /** AttachmentProcessor.Result → RN 결과 맵 (예전 브릿지와 같은 필드·타입) */
1072
+ private fun toWritableMap(r: com.rnattachmentkit.AttachmentProcessor.Result): WritableMap = WritableNativeMap().apply {
1073
+ r.toMap().forEach { (k, v) ->
1074
+ when (v) {
1075
+ is Boolean -> putBoolean(k, v)
1076
+ is Int -> putInt(k, v)
1077
+ is Number -> putDouble(k, v.toDouble())
1078
+ else -> putString(k, v.toString())
1317
1079
  }
1318
1080
  }
1319
-
1320
- return inSampleSize
1321
- }
1322
-
1323
- private fun resizeBitmap(bitmap: Bitmap, maxWidth: Int, maxHeight: Int): Bitmap {
1324
- val width = bitmap.width
1325
- val height = bitmap.height
1326
-
1327
- val ratioBitmap = width.toFloat() / height.toFloat()
1328
- val ratioMax = maxWidth.toFloat() / maxHeight.toFloat()
1329
-
1330
- var finalWidth = maxWidth
1331
- var finalHeight = maxHeight
1332
-
1333
- if (ratioMax > ratioBitmap) {
1334
- finalWidth = (maxHeight * ratioBitmap).toInt()
1335
- } else {
1336
- finalHeight = (maxWidth / ratioBitmap).toInt()
1337
- }
1338
-
1339
- return Bitmap.createScaledBitmap(bitmap, finalWidth, finalHeight, true)
1340
1081
  }
1341
1082
 
1342
1083