react-native-attachment-kit 1.0.2 → 1.0.6

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 (147) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/README.md +446 -206
  3. package/android/build.gradle +17 -0
  4. package/android/src/main/libs/rnattachmentkit-release.aar +0 -0
  5. package/android/src/newarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +587 -22
  6. package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +592 -21
  7. package/ios/RNAttachmentKit.m +62 -0
  8. package/ios/RNAttachmentKit.swift +725 -29
  9. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Assets.car +0 -0
  10. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Headers/RNAttachmentKit-Swift.h +51 -5
  11. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Info.plist +0 -0
  12. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.abi.json +51876 -18835
  13. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +310 -5
  14. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  15. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftinterface +310 -5
  16. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  17. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_crop_reflect~dark.webp +0 -0
  18. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_crop_reflect~light.webp +0 -0
  19. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_crop_rotate~dark.webp +0 -0
  20. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_crop_rotate~light.webp +0 -0
  21. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_delete~dark.webp +0 -0
  22. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_delete~light.webp +0 -0
  23. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_forward~dark.webp +0 -0
  24. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_forward~light.webp +0 -0
  25. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_reward~dark.webp +0 -0
  26. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_reward~light.webp +0 -0
  27. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_back~dark.webp +0 -0
  28. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_back~light.webp +0 -0
  29. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_blur~dark.webp +0 -0
  30. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_blur~light.webp +0 -0
  31. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_check_small~dark.webp +0 -0
  32. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_check_small~light.webp +0 -0
  33. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_check~dark.webp +0 -0
  34. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_check~light.webp +0 -0
  35. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_close~dark.webp +0 -0
  36. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_close~light.webp +0 -0
  37. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_crop~dark.webp +0 -0
  38. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_crop~light.webp +0 -0
  39. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_drawing~dark.webp +0 -0
  40. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_drawing~light.webp +0 -0
  41. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_effect~dark.webp +0 -0
  42. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_effect~light.webp +0 -0
  43. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_emoji~dark.webp +0 -0
  44. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_emoji~light.webp +0 -0
  45. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_eraser~dark.webp +0 -0
  46. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_eraser~light.webp +0 -0
  47. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_mosaic~dark.webp +0 -0
  48. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_mosaic~light.webp +0 -0
  49. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_text~dark.webp +0 -0
  50. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_text~light.webp +0 -0
  51. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_text_add~dark.webp +0 -0
  52. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_text_add~light.webp +0 -0
  53. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  54. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeResources +195 -63
  55. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeSignature +0 -0
  56. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Assets.car +0 -0
  57. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Headers/RNAttachmentKit-Swift.h +102 -10
  58. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Info.plist +0 -0
  59. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +51876 -18835
  60. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +310 -5
  61. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  62. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftinterface +310 -5
  63. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +51876 -18835
  64. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +310 -5
  65. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  66. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +310 -5
  67. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  68. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_crop_reflect~dark.webp +0 -0
  69. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_crop_reflect~light.webp +0 -0
  70. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_crop_rotate~dark.webp +0 -0
  71. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_crop_rotate~light.webp +0 -0
  72. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_delete~dark.webp +0 -0
  73. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_delete~light.webp +0 -0
  74. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_forward~dark.webp +0 -0
  75. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_forward~light.webp +0 -0
  76. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_reward~dark.webp +0 -0
  77. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_reward~light.webp +0 -0
  78. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_back~dark.webp +0 -0
  79. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_back~light.webp +0 -0
  80. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_blur~dark.webp +0 -0
  81. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_blur~light.webp +0 -0
  82. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_check_small~dark.webp +0 -0
  83. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_check_small~light.webp +0 -0
  84. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_check~dark.webp +0 -0
  85. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_check~light.webp +0 -0
  86. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_close~dark.webp +0 -0
  87. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_close~light.webp +0 -0
  88. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_crop~dark.webp +0 -0
  89. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_crop~light.webp +0 -0
  90. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_drawing~dark.webp +0 -0
  91. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_drawing~light.webp +0 -0
  92. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_effect~dark.webp +0 -0
  93. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_effect~light.webp +0 -0
  94. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_emoji~dark.webp +0 -0
  95. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_emoji~light.webp +0 -0
  96. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_eraser~dark.webp +0 -0
  97. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_eraser~light.webp +0 -0
  98. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_mosaic~dark.webp +0 -0
  99. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_mosaic~light.webp +0 -0
  100. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_text~dark.webp +0 -0
  101. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_text~light.webp +0 -0
  102. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_text_add~dark.webp +0 -0
  103. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_text_add~light.webp +0 -0
  104. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  105. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeResources +205 -73
  106. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_crop_reflect~dark.webp +0 -0
  107. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_crop_reflect~light.webp +0 -0
  108. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_crop_rotate~dark.webp +0 -0
  109. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_crop_rotate~light.webp +0 -0
  110. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_drawing_delete~dark.webp +0 -0
  111. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_drawing_delete~light.webp +0 -0
  112. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_drawing_forward~dark.webp +0 -0
  113. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_drawing_forward~light.webp +0 -0
  114. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_drawing_reward~dark.webp +0 -0
  115. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_drawing_reward~light.webp +0 -0
  116. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_back~dark.webp +0 -0
  117. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_back~light.webp +0 -0
  118. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_blur~dark.webp +0 -0
  119. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_blur~light.webp +0 -0
  120. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_check_small~dark.webp +0 -0
  121. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_check_small~light.webp +0 -0
  122. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_check~dark.webp +0 -0
  123. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_check~light.webp +0 -0
  124. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_close~dark.webp +0 -0
  125. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_close~light.webp +0 -0
  126. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_crop~dark.webp +0 -0
  127. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_crop~light.webp +0 -0
  128. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_drawing~dark.webp +0 -0
  129. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_drawing~light.webp +0 -0
  130. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_effect~dark.webp +0 -0
  131. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_effect~light.webp +0 -0
  132. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_emoji~dark.webp +0 -0
  133. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_emoji~light.webp +0 -0
  134. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_eraser~dark.webp +0 -0
  135. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_eraser~light.webp +0 -0
  136. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_mosaic~dark.webp +0 -0
  137. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_mosaic~light.webp +0 -0
  138. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_text~dark.webp +0 -0
  139. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_text~light.webp +0 -0
  140. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_text_add~dark.webp +0 -0
  141. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_text_add~light.webp +0 -0
  142. package/package.json +1 -1
  143. package/react-native-attachment-kit.podspec +1 -1
  144. package/src/NativeAttachmentKit.ts +15 -0
  145. package/src/index.d.ts +5 -56
  146. package/src/index.ts +301 -37
  147. package/src/types.ts +380 -11
@@ -8,6 +8,7 @@
8
8
  import UIKit
9
9
  import Photos
10
10
  import ImageIO
11
+ import CoreLocation
11
12
  import RNAttachmentKit
12
13
 
13
14
  #if RCT_NEW_ARCH_ENABLED
@@ -48,6 +49,13 @@ class RNAttachmentKit: RCTEventEmitter {
48
49
  private var editorTransitionDelegate: ImageEditorTransitionDelegate?
49
50
  /// 열려 있는 미디어 갤러리 (galleryId → 화면)
50
51
  fileprivate var mediaGalleries: [String: MediaGalleryViewController] = [:]
52
+ /// 진행 중인 전송 처리 (processingId → 작업, 메인 스레드에서만). 보내는 중에 갤러리를 다시 열어 또 보낼 수 있어 여러 개일 수 있음
53
+ private var processingJobs: [String: MediaProcessingJob] = [:]
54
+ /// 가장 최근에 시작한 처리 (cancelVideoProcessing 에 processingId 가 없을 때)
55
+ private var lastProcessingId: String?
56
+ /// 동영상 변환 진행 HUD 를 띄울지 — video.showProgressHUD, 없으면 asyncProcessing 이 아닐 때만
57
+ /// (앱이 onProcessingStart / onVideoProgress / onImageProcessed 로 말풍선에 진행 상황을 그리는 구조면 HUD 없이)
58
+ private var galleryShowsVideoHUD = true
51
59
 
52
60
  // MARK: - Module Setup
53
61
 
@@ -64,7 +72,9 @@ class RNAttachmentKit: RCTEventEmitter {
64
72
  @objc
65
73
  override func supportedEvents() -> [String]! {
66
74
  return ["onSelectionChange", "onImageProcessed", "onPageSelected", "onViewerWillClose", "onImageDeleted",
67
- "onGalleryItemDeleted", "onGalleryNeedPage", "onGalleryClosed", "onGalleryGridSizeChanged"]
75
+ "onGalleryItemDeleted", "onGalleryNeedPage", "onGalleryClosed", "onGalleryGridSizeChanged",
76
+ "onLocationSelected", "onFilesSelected", "onAIUsage", "onAIChargeRequired", "onVideoProgress",
77
+ "onMapViewerWillClose", "onProcessingStart"]
68
78
  }
69
79
 
70
80
  // MARK: - Public Methods
@@ -171,6 +181,230 @@ class RNAttachmentKit: RCTEventEmitter {
171
181
  }
172
182
  }
173
183
 
184
+ // MARK: - Map Viewer (위치 메시지 → 지도)
185
+
186
+ private weak var activeMapViewerVC: MapViewerViewController?
187
+ /// updateMapViewerSourceRect 로 갱신되는 닫힘 위치 (window points)
188
+ private var mapViewerSourceRect: CGRect?
189
+
190
+ private static func cgFloat(_ v: Any?) -> CGFloat? {
191
+ if let n = v as? NSNumber { return CGFloat(truncating: n) }
192
+ if let d = v as? Double { return CGFloat(d) }
193
+ return nil
194
+ }
195
+
196
+ private static func parseRect(_ v: Any?) -> CGRect? {
197
+ guard let d = v as? NSDictionary,
198
+ let x = cgFloat(d["x"]), let y = cgFloat(d["y"]),
199
+ let w = cgFloat(d["width"]), let h = cgFloat(d["height"]), w > 0, h > 0 else { return nil }
200
+ return CGRect(x: x, y: y, width: w, height: h)
201
+ }
202
+
203
+ /// 말풍선 이미지(정적 지도 스냅샷) 로드. 메모리/디스크 캐시 → file:// → 다운로드 순.
204
+ /// 실패하거나 너무 오래 걸리면 nil (이미지 없이 지도만 엶)
205
+ /// completion(이미지, 마커 뺀 이미지인지). renderLocationThumbnail 결과면 마커 뺀 짝을 씀 → 뷰어가 마커를 따로 그려
206
+ /// 말풍선 속 크기 ↔ 지도 마커 크기로 자연스럽게 바꿈
207
+ private func loadMapSourceImage(_ uri: String?, completion: @escaping (UIImage?, Bool) -> Void) {
208
+ guard let uri = uri, !uri.isEmpty else { completion(nil, false); return }
209
+ if uri.hasPrefix("file://") || uri.hasPrefix("/") {
210
+ let path = uri.hasPrefix("file://") ? (URL(string: uri)?.path ?? String(uri.dropFirst(7))) : uri
211
+ DispatchQueue.global(qos: .userInitiated).async {
212
+ if let base = LocationThumbnailRenderer.markerlessFile(for: path), let img = UIImage(contentsOfFile: base) {
213
+ DispatchQueue.main.async { completion(img, true) }
214
+ return
215
+ }
216
+ let image = UIImage(contentsOfFile: path)
217
+ DispatchQueue.main.async { completion(image, false) }
218
+ }
219
+ return
220
+ }
221
+ if let cached = ViewerImageCache.shared.getMemoryImage(for: uri) { completion(cached, false); return }
222
+ var done = false
223
+ let finish: (UIImage?) -> Void = { image in
224
+ DispatchQueue.main.async {
225
+ guard !done else { return }
226
+ done = true
227
+ completion(image, false)
228
+ }
229
+ }
230
+ // 다운로드가 늦으면 이미지 없이 바로 엶 (탭 반응이 늦어지지 않게)
231
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { finish(nil) }
232
+ DispatchQueue.global(qos: .userInitiated).async {
233
+ if let disk = ViewerImageCache.shared.getDiskImageSynchronously(for: uri) { finish(disk); return }
234
+ RemoteImageViewerViewController.prefetchImage(from: uri) { finish($0) }
235
+ }
236
+ }
237
+
238
+ @objc
239
+ func openMapViewer(_ options: NSDictionary,
240
+ resolve: @escaping RCTPromiseResolveBlock,
241
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
242
+ guard let lat = RNAttachmentKit.cgFloat(options["latitude"]),
243
+ let lng = RNAttachmentKit.cgFloat(options["longitude"]) else {
244
+ reject("ERROR", "latitude and longitude are required", nil)
245
+ return
246
+ }
247
+ let sourceRect = RNAttachmentKit.parseRect(options["sourceRect"])
248
+ let imageUri = options["sourceImageUri"] as? String
249
+ DispatchQueue.main.async {
250
+ // 위치 정보만으로 여는 경우엔 이미지 로드를 기다리지 않음
251
+ let load: (@escaping (UIImage?, Bool) -> Void) -> Void = { done in
252
+ if sourceRect == nil { done(nil, false) } else { self.loadMapSourceImage(imageUri, completion: done) }
253
+ }
254
+ load { image, markerless in
255
+ guard let root = self.getRootViewController() else {
256
+ reject("ERROR", "Unable to get root view controller", nil)
257
+ return
258
+ }
259
+ var accent = UIColor(red: 1, green: 0.42, blue: 0.21, alpha: 1)
260
+ if let hex = options["themeColor"] as? String, let c = UIColor(hexString: hex) { accent = c }
261
+ let vc = MapViewerViewController(
262
+ latitude: Double(lat), longitude: Double(lng),
263
+ title: options["title"] as? String,
264
+ address: options["address"] as? String,
265
+ markerType: options["markerType"] as? String,
266
+ placeCategory: options["placeCategory"] as? String,
267
+ accentColor: accent,
268
+ languageCode: options["languageCode"] as? String ?? "en")
269
+ vc.overrideUserInterfaceStyle = RNAttachmentKit.interfaceStyle(options)
270
+ vc.showsDirections = (options["showDirections"] as? Bool) ?? true
271
+ vc.addressLanguageCode = (options["addressLanguageCode"] as? String).flatMap { $0.isEmpty ? nil : $0 }
272
+ if let span = RNAttachmentKit.cgFloat(options["spanMeters"]), span > 0 { vc.spanMeters = Double(span) }
273
+ if let r = RNAttachmentKit.cgFloat(options["sourceCornerRadius"]) { vc.sourceCornerRadius = r }
274
+ self.mapViewerSourceRect = sourceRect
275
+ if let rect = sourceRect {
276
+ vc.sourceRect = rect
277
+ vc.sourceImage = image
278
+ vc.sourceImageIsMarkerless = markerless && image != nil
279
+ vc.sourceRectProvider = { [weak self] in self?.mapViewerSourceRect }
280
+ }
281
+ vc.onWillClose = { [weak self] dur in
282
+ self?.sendEvent(withName: "onMapViewerWillClose", body: ["durationMs": Int(dur * 1000)])
283
+ }
284
+ vc.onClosed = { [weak self] in
285
+ self?.mapViewerSourceRect = nil
286
+ resolve(nil)
287
+ }
288
+ self.activeMapViewerVC = vc
289
+ root.present(vc, animated: sourceRect == nil)
290
+ }
291
+ }
292
+ }
293
+
294
+ @objc
295
+ func updateMapViewerSourceRect(_ rect: NSDictionary,
296
+ resolve: @escaping RCTPromiseResolveBlock,
297
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
298
+ let parsed = RNAttachmentKit.parseRect(rect)
299
+ DispatchQueue.main.async {
300
+ if self.activeMapViewerVC != nil { self.mapViewerSourceRect = parsed }
301
+ resolve(nil)
302
+ }
303
+ }
304
+
305
+ /// 위치 메시지 말풍선 지도 썸네일을 이 기기에서 그려 캐시 (보낸 이미지 없이 위치 정보만으로)
306
+ @objc
307
+ func renderLocationThumbnail(_ options: NSDictionary,
308
+ resolve: @escaping RCTPromiseResolveBlock,
309
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
310
+ guard let lat = RNAttachmentKit.cgFloat(options["latitude"]),
311
+ let lng = RNAttachmentKit.cgFloat(options["longitude"]),
312
+ let w = RNAttachmentKit.cgFloat(options["width"]), w > 0 else {
313
+ reject("E_INVALID_ARGS", "latitude, longitude and width are required", nil)
314
+ return
315
+ }
316
+ DispatchQueue.main.async {
317
+ // 비율(aspect)이 있으면 세로는 가로에서 계산, 없으면 height 그대로
318
+ guard let h = LocationThumbnailRenderer.height(forWidth: w, aspect: options["aspect"] as? String)
319
+ ?? RNAttachmentKit.cgFloat(options["height"]), h > 0 else {
320
+ reject("E_INVALID_ARGS", "height (or aspect) is required", nil)
321
+ return
322
+ }
323
+ var accent = UIColor(red: 1, green: 0.42, blue: 0.21, alpha: 1)
324
+ if let hex = options["themeColor"] as? String, let c = UIColor(hexString: hex) { accent = c }
325
+ var style = RNAttachmentKit.interfaceStyle(options)
326
+ if style == .unspecified {
327
+ // "system": 지금 앱 화면 모드
328
+ style = UIApplication.shared.connectedScenes.compactMap { ($0 as? UIWindowScene)?.windows.first(where: \.isKeyWindow) }
329
+ .first?.traitCollection.userInterfaceStyle ?? UITraitCollection.current.userInterfaceStyle
330
+ }
331
+ var me: CLLocationCoordinate2D?
332
+ if let mlat = RNAttachmentKit.cgFloat(options["myLatitude"]), let mlng = RNAttachmentKit.cgFloat(options["myLongitude"]) {
333
+ me = CLLocationCoordinate2D(latitude: Double(mlat), longitude: Double(mlng))
334
+ }
335
+ let span = RNAttachmentKit.cgFloat(options["spanMeters"]).map { Double($0) }
336
+ var req = LocationThumbnailRequest(
337
+ latitude: Double(lat), longitude: Double(lng),
338
+ size: CGSize(width: w, height: h), scale: min(UIScreen.main.scale, 3),
339
+ isDark: style == .dark,
340
+ markerType: options["markerType"] as? String,
341
+ placeCategory: options["placeCategory"] as? String,
342
+ spanMeters: (span ?? 0) > 0 ? span : nil,
343
+ accentColor: accent,
344
+ myLocation: me)
345
+ // 결과 이미지 최대 크기 (px). 둘 다 없으면 512 × 512, 하나만 주면 다른 쪽은 제한 없음
346
+ // (예: maxWidth 512 만 주고 세로가 기본 512 에 잘려 작아지는 일이 없게)
347
+ let maxW = RNAttachmentKit.cgFloat(options["maxWidth"]).flatMap { $0 > 0 ? $0 : nil }
348
+ let maxH = RNAttachmentKit.cgFloat(options["maxHeight"]).flatMap { $0 > 0 ? $0 : nil }
349
+ if maxW != nil || maxH != nil {
350
+ req.maxPixelWidth = maxW ?? .greatestFiniteMagnitude
351
+ req.maxPixelHeight = maxH ?? .greatestFiniteMagnitude
352
+ }
353
+ LocationThumbnailRenderer.render(req) { result in
354
+ switch result {
355
+ case .success(let dict): resolve(dict)
356
+ case .failure(let error): reject("E_THUMBNAIL_FAILED", error.localizedDescription, error)
357
+ }
358
+ }
359
+ }
360
+ }
361
+
362
+ // MARK: - Reverse geocode (기기 지오코더, 디스크 캐시)
363
+
364
+ @objc
365
+ func reverseGeocode(_ options: NSDictionary,
366
+ resolve: @escaping RCTPromiseResolveBlock,
367
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
368
+ let lat = (options["latitude"] as? NSNumber)?.doubleValue ?? .nan
369
+ let lng = (options["longitude"] as? NSNumber)?.doubleValue ?? .nan
370
+ let lang = (options["languageCode"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "en"
371
+ ReverseGeocoder.shared.reverseGeocode(latitude: lat, longitude: lng, languageCode: lang) { result in
372
+ switch result {
373
+ case .success(let r): resolve(r.dictionary)
374
+ case .failure(let e): reject(e.code, e.message, nil)
375
+ }
376
+ }
377
+ }
378
+
379
+ /// AI 기능 설정·잔액 (넘긴 값만 바뀜). 사용·충전 요청은 onAIUsage / onAIChargeRequired 이벤트로 알림
380
+ @objc
381
+ func setAIConfig(_ config: NSDictionary,
382
+ resolve: @escaping RCTPromiseResolveBlock,
383
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
384
+ let dict = config as? [String: Any] ?? [:]
385
+ DispatchQueue.main.async {
386
+ AIBilling.apply(dict)
387
+ AIBilling.onUsage = { [weak self] body in self?.sendEvent(withName: "onAIUsage", body: body) }
388
+ AIBilling.onChargeRequired = { [weak self] body in self?.sendEvent(withName: "onAIChargeRequired", body: body) }
389
+ resolve(nil)
390
+ }
391
+ }
392
+
393
+ @objc
394
+ func clearReverseGeocodeCache(_ resolve: @escaping RCTPromiseResolveBlock,
395
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
396
+ ReverseGeocoder.shared.clearCache { resolve(nil) }
397
+ }
398
+
399
+ @objc
400
+ func clearLocationThumbnailCache(_ resolve: @escaping RCTPromiseResolveBlock,
401
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
402
+ DispatchQueue.global(qos: .utility).async {
403
+ LocationThumbnailRenderer.clearCache()
404
+ resolve(nil)
405
+ }
406
+ }
407
+
174
408
  @objc
175
409
  func openViewer(_ options: NSDictionary,
176
410
  resolve: @escaping RCTPromiseResolveBlock,
@@ -184,6 +418,8 @@ class RNAttachmentKit: RCTEventEmitter {
184
418
  let placeholderImages = options["placeholderImages"] as? [String]
185
419
  // cacheFiles: images 와 같은 순서의 로컬 캐시 파일 경로 (null 항목은 NSNull 로 옴 → 빈 문자열)
186
420
  let cacheFiles: [String]? = (options["cacheFiles"] as? [Any])?.map { ($0 as? String) ?? "" }
421
+ // videos: images 와 같은 순서. 항목이 있으면 그 페이지는 동영상 (images[i] 는 포스터)
422
+ let viewerVideos: [ViewerVideoItem?] = (options["videos"] as? [Any])?.map { ViewerVideoItem(dictionary: $0) } ?? []
187
423
  let themeColorHex = options["themeColor"] as? String
188
424
  let languageCode = options["languageCode"] as? String ?? "en"
189
425
  let viewerTitle = options["title"] as? String
@@ -267,6 +503,7 @@ class RNAttachmentKit: RCTEventEmitter {
267
503
  viewerVC.viewerTitle = viewerTitle
268
504
  viewerVC.placeholderImages = placeholderImages
269
505
  viewerVC.cacheFiles = cacheFiles
506
+ viewerVC.videos = viewerVideos
270
507
  if let hex = themeColorHex, let color = UIColor(hexString: hex) {
271
508
  viewerVC.themeColor = color
272
509
  }
@@ -560,30 +797,42 @@ class RNAttachmentKit: RCTEventEmitter {
560
797
  resolve: @escaping RCTPromiseResolveBlock,
561
798
  reject: @escaping RCTPromiseRejectBlock) -> Void {
562
799
  let startTime = CFAbsoluteTimeGetCurrent()
800
+ #if DEBUG
563
801
  print("✅ [DEBUG] received options: \(options)")
564
802
  print("🔵 [DEBUG] openGallery 시작 - 시간: \(startTime)")
565
-
803
+ #endif
804
+
805
+ // 예전에 파일/위치 탭에서 임시 폴더에 복사해 두고 24시간이 지난 것 정리 (백그라운드, UI 와 무관)
806
+ DispatchQueue.global(qos: .utility).async {
807
+ TempFileCleaner.cleanupStaleTempFiles()
808
+ }
809
+
566
810
  // Main thread에서 실행
567
811
  DispatchQueue.main.async { [weak self] () -> Void in
568
812
  guard let self = self else {
569
813
  reject("ERROR", "Module deallocated", nil)
570
814
  return
571
815
  }
572
-
816
+
817
+ #if DEBUG
573
818
  print("🔵 [DEBUG] workItem 실행 시작 - 경과: \(String(format: "%.3f", CFAbsoluteTimeGetCurrent() - startTime))초")
574
-
819
+ #endif
820
+
575
821
  let step1 = CFAbsoluteTimeGetCurrent()
576
822
  guard let rootViewController = self.getRootViewController() else {
577
823
  print("❌ [DEBUG] getRootViewController 실패")
578
824
  reject("ERROR", "Unable to get root view controller", nil)
579
825
  return
580
826
  }
827
+ #if DEBUG
581
828
  print("🔵 [DEBUG] getRootViewController 완료 - 경과: \(String(format: "%.3f", CFAbsoluteTimeGetCurrent() - step1))초")
582
-
829
+ #endif
830
+
583
831
  let step2 = CFAbsoluteTimeGetCurrent()
584
- print("🔵 [DEBUG] GalleryViewController 생성 시작...")
585
832
  let galleryVC = GalleryViewController()
833
+ #if DEBUG
586
834
  print("🔵 [DEBUG] GalleryViewController 생성 완료 - 경과: \(String(format: "%.3f", CFAbsoluteTimeGetCurrent() - step2))초")
835
+ #endif
587
836
 
588
837
  // 현재 갤러리 참조 저장
589
838
  self.currentGalleryVC = galleryVC
@@ -679,6 +928,8 @@ class RNAttachmentKit: RCTEventEmitter {
679
928
  editorVC.onCancel = { [weak currentGallery] in
680
929
  currentGallery?.selectedAssets.removeAll()
681
930
  currentGallery?.refreshSelectionAfterEdit()
931
+ // 결과 전달 책임이 갤러리로 돌아옴 → 닫기가 다시 취소(reject)를 보낼 수 있게
932
+ currentGallery?.hasReturnedImages = false
682
933
  }
683
934
 
684
935
  var savedDetent: UISheetPresentationController.Detent.Identifier? = nil
@@ -714,7 +965,45 @@ class RNAttachmentKit: RCTEventEmitter {
714
965
  }
715
966
  }
716
967
 
717
- galleryVC.onImagesSelected = { [weak self] selectedImages in
968
+ // 첨부 메뉴(갤러리 / 위치 / 파일). 기본 false — 켜지 않으면 기존과 같음
969
+ galleryVC.showsAttachMenu = options["showAttachMenu"] as? Bool ?? false
970
+ // 위치 보내기 때 지도 캡처 이미지 (기본 true). false 면 위치 정보만 — 받는 쪽이 renderLocationThumbnail 로 그림
971
+ galleryVC.capturesLocationImage = options["captureLocationImage"] as? Bool ?? true
972
+ // 동영상: mediaType "photo"(기본) | "video" | "all" + video 옵션 (지시서 3-1)
973
+ let mediaTypeOption = (options["mediaType"] as? String)?.lowercased() ?? "photo"
974
+ galleryVC.mediaType = ["photo", "video", "all"].contains(mediaTypeOption) ? mediaTypeOption : "photo"
975
+ let videoOptions = VideoExportOptions(dictionary: options["video"] as? [String: Any])
976
+ let asyncProcessing = options["asyncProcessing"] as? Bool ?? false
977
+ self.galleryShowsVideoHUD = ((options["video"] as? [String: Any])?["showProgressHUD"] as? Bool) ?? !asyncProcessing
978
+ galleryVC.maxVideoDurationSec = videoOptions.maxDurationSec
979
+ galleryVC.videoExportOptions = videoOptions
980
+ // 위치 탭에서 보내기 → onLocationSelected 이벤트로 결과를 넘기고, openGallery 는 빈 배열로 끝냄
981
+ galleryVC.onLocationSelected = { [weak self, weak galleryVC] location in
982
+ self?.currentGalleryVC = nil
983
+ self?.sendEvent(withName: "onLocationSelected", body: location.dictionary)
984
+ resolve([])
985
+ galleryVC?.releaseAfterReturn()
986
+ }
987
+ // 파일 탭에서 보내기 → onFilesSelected 이벤트 { files: [{uri, path, name, size, mimeType}] }, openGallery 는 빈 배열
988
+ // 일부 파일만 복사에 실패하면 (추가 필드) failed: [{name, error}] 를 함께 보냄 (files 는 그대로)
989
+ galleryVC.onFilesSelectedWithFailures = { [weak self, weak galleryVC] files, failures in
990
+ self?.currentGalleryVC = nil
991
+ var body: [String: Any] = ["files": files.map { $0.dictionary }]
992
+ if !failures.isEmpty { body["failed"] = failures.map { $0.dictionary } }
993
+ self?.sendEvent(withName: "onFilesSelected", body: body)
994
+ resolve([])
995
+ galleryVC?.releaseAfterReturn()
996
+ }
997
+
998
+ // ⚠️ 클로저 안에서 galleryVC 를 강하게 잡으면 galleryVC → onImagesSelected → galleryVC 순환 참조가 되어
999
+ // (성공 경로는 viewDidDisappear 가 정리를 deinit 으로 미루므로) 갤러리가 영영 해제되지 않았습니다.
1000
+ // 필요한 값은 미리 상수로 꺼내고, galleryVC 는 weak 로만 씁니다.
1001
+ let galleryMaxWidth = galleryVC.maxWidth
1002
+ let galleryMaxHeight = galleryVC.maxHeight
1003
+ let galleryFormat = galleryVC.outputFormat // "webp" | "jpg" | "png"
1004
+ let galleryAccent = galleryVC.parsedThemeColor
1005
+ let galleryLanguage = galleryVC.languageCode
1006
+ galleryVC.onImagesSelected = { [weak self, weak galleryVC] selectedImages in
718
1007
  guard let self = self else { resolve([]); return }
719
1008
 
720
1009
  guard !selectedImages.isEmpty else {
@@ -725,21 +1014,137 @@ class RNAttachmentKit: RCTEventEmitter {
725
1014
 
726
1015
  var results: [[String: Any]] = []
727
1016
  let tempDir = self.prepareCacheDirectory()
728
- let maxWidth = galleryVC.maxWidth
729
- let maxHeight = galleryVC.maxHeight
730
- let format = galleryVC.outputFormat // "webp" | "jpg" | "png"
1017
+ let maxWidth = galleryMaxWidth
1018
+ let maxHeight = galleryMaxHeight
1019
+ let format = galleryFormat
731
1020
  let mimeType = self.mimeType(for: format)
732
1021
  let needResize = (maxWidth > 0 || maxHeight > 0)
1022
+ let total = selectedImages.count
733
1023
 
734
1024
  let resultsLock = NSLock()
735
-
1025
+ // 처리한 이미지는 바로 놓아 주도록 (전체 UIImage 배열을 끝까지 들고 있지 않게) 옵셔널 배열로 옮김
1026
+ var pending: [(PHAsset?, UIImage)?] = selectedImages.map { $0 }
1027
+
1028
+ // 동영상이 섞여 있으면 변환 진행 표시 (갤러리가 닫힌 뒤에도 보이게 키 윈도우에) + 취소
1029
+ let hasVideo = selectedImages.contains { $0.0?.mediaType == .video }
1030
+ // 편집 화면에서 고른 품질·음소거 (없으면 기본 품질)
1031
+ let videoStates = galleryVC?.videoEditStates ?? [:]
1032
+ var hud: VideoExportHUD?
1033
+ var currentExporter: VideoExporter?
1034
+ var cancelledAll = false
1035
+ let exporterLock = NSLock()
1036
+ // 앱이 동영상 하나(또는 전부)를 취소할 수 있게 (cancelVideoProcessing)
1037
+ let job = MediaProcessingJob()
1038
+ let processingId = UUID().uuidString
1039
+ self.processingJobs[processingId] = job
1040
+ self.lastProcessingId = processingId
1041
+ // 처리 시작 이벤트용 — 고른 순서의 원본(사진은 편집 결과) 크기·포스터
1042
+ let startItems = selectedImages.map { ($0.0, $0.1) }
1043
+ if hasVideo && self.galleryShowsVideoHUD {
1044
+ hud = VideoExportHUD.show(accent: galleryAccent, languageCode: galleryLanguage)
1045
+ hud?.onCancel = {
1046
+ exporterLock.lock(); cancelledAll = true; currentExporter?.cancel(); exporterLock.unlock()
1047
+ }
1048
+ }
1049
+
736
1050
  DispatchQueue.global(qos: .userInitiated).async {
737
- for (index, pair) in selectedImages.enumerated() {
1051
+ // ✅ 처리 시작: 모든 자리(사진·동영상)를 먼저 알림 → 앱이 말풍선을 바로 만들고 진행률을 그림
1052
+ self.sendEvent(withName: "onProcessingStart", body: [
1053
+ "processingId": processingId,
1054
+ "total": total,
1055
+ "items": startItems.enumerated().map { i, pair in
1056
+ self.processingStartItem(index: i, asset: pair.0, image: pair.1, videoStates: videoStates,
1057
+ videoOptions: videoOptions, maxWidth: maxWidth, maxHeight: maxHeight,
1058
+ tempDir: tempDir)
1059
+ },
1060
+ ])
1061
+ for index in 0..<total {
1062
+ guard let pair = pending[index] else { continue }
1063
+ pending[index] = nil
1064
+ exporterLock.lock(); let stop = cancelledAll; exporterLock.unlock()
1065
+ if stop { break }
1066
+ autoreleasepool {
738
1067
  let (asset, image) = pair
739
1068
  let timestamp = Int(Date().timeIntervalSince1970 * 1000)
740
1069
  let originalSize = image.size
741
1070
 
742
- if let phAsset = asset {
1071
+ if let phAsset = asset, phAsset.mediaType == .video {
1072
+ // ✅ 동영상: VideoExporter 로 전송용 mp4 (+ 썸네일·메타). image 는 포스터(목록용)
1073
+ let exporter = VideoExporter()
1074
+ exporterLock.lock(); currentExporter = exporter; exporterLock.unlock()
1075
+ let base = "turbo_video_\(timestamp)_\(index)"
1076
+ var result: [String: Any] = [
1077
+ "originalUri": "ph://\(phAsset.localIdentifier)",
1078
+ "originalWidth": phAsset.pixelWidth,
1079
+ "originalHeight": phAsset.pixelHeight,
1080
+ "mediaType": "video",
1081
+ "type": "video/mp4",
1082
+ "duration": phAsset.duration,
1083
+ ]
1084
+ do {
1085
+ // 앱이 이미 취소한 동영상은 건너뜀 (결과에 cancelled 로)
1086
+ if job.isCancelled(index) { throw VideoExporter.ExportError.cancelled }
1087
+ job.begin(index: index, exporter: exporter)
1088
+ let state = videoStates[phAsset.localIdentifier] ?? VideoEditState.defaultState(for: phAsset, options: videoOptions)
1089
+ let throttle = ProgressThrottle()
1090
+ let onProgress: (Double) -> Void = { p in
1091
+ // 프레임마다 메인으로 보내지 않고 1%·0.1초 단위로만 (HUD 표시는 정수 % 라 같음)
1092
+ guard throttle.shouldSend(p) else { return }
1093
+ DispatchQueue.main.async { hud?.update(progress: p, index: index, total: total) }
1094
+ self.sendEvent(withName: "onVideoProgress", body: ["index": index, "total": total, "progress": p, "processingId": processingId])
1095
+ }
1096
+ // 편집 화면에서 미리 만들어 둔 파일이 있으면(또는 만드는 중이면 끝나고) 그대로 씀
1097
+ let r: VideoExportResult
1098
+ // 기다리는 동안 앱(X)이나 HUD 가 취소하면 바로 멈춤 (전엔 미리 만들기가 끝날 때까지 못 빠져나왔음)
1099
+ let stop = { () -> Bool in
1100
+ exporterLock.lock(); let all = cancelledAll; exporterLock.unlock()
1101
+ return all || job.isCancelled(index)
1102
+ }
1103
+ if let ready = VideoPrepareManager.shared.takeReady(asset: phAsset, state: state, progress: onProgress, isCancelled: stop) {
1104
+ r = ready; onProgress(1)
1105
+ } else {
1106
+ if stop() { throw VideoExporter.ExportError.cancelled }
1107
+ r = try exporter.export(asset: phAsset, state: state,
1108
+ options: videoOptions, outputDir: URL(fileURLWithPath: tempDir), baseName: base, progress: onProgress)
1109
+ }
1110
+ // 미리 만든 파일을 기다리는 동안 취소됐으면 결과를 쓰지 않음
1111
+ if job.isCancelled(index) { throw VideoExporter.ExportError.cancelled }
1112
+ result["uri"] = "file://" + r.url.path
1113
+ result["width"] = r.width
1114
+ result["height"] = r.height
1115
+ result["duration"] = r.duration
1116
+ result["fileSize"] = r.fileSize
1117
+ result["fileName"] = r.url.lastPathComponent
1118
+ result["fileExtension"] = "mp4"
1119
+ result["videoCodec"] = r.codec
1120
+ result["videoBitrate"] = r.bitrate
1121
+ result["edited"] = !r.passthrough
1122
+ if let t = r.thumbnailURL {
1123
+ result["thumbnailUri"] = "file://" + t.path
1124
+ result["thumbnailWidth"] = r.thumbnailWidth
1125
+ result["thumbnailHeight"] = r.thumbnailHeight
1126
+ }
1127
+ } catch VideoExporter.ExportError.cancelled where job.isCancelled(index) {
1128
+ // 앱이 이 동영상만 취소 → 나머지는 계속
1129
+ result["error"] = "CANCELLED"
1130
+ result["cancelled"] = true
1131
+ } catch VideoExporter.ExportError.cancelled {
1132
+ // HUD 의 취소 → 전체 취소 (Promise reject)
1133
+ job.end()
1134
+ exporterLock.lock(); cancelledAll = true; exporterLock.unlock()
1135
+ return
1136
+ } catch {
1137
+ result["error"] = error.localizedDescription
1138
+ }
1139
+ job.end()
1140
+ exporterLock.lock(); currentExporter = nil; exporterLock.unlock()
1141
+
1142
+ resultsLock.lock()
1143
+ results.append(result)
1144
+ let currentResult = result
1145
+ resultsLock.unlock()
1146
+ self.sendEvent(withName: "onImageProcessed", body: ["index": index, "total": total, "image": currentResult, "processingId": processingId])
1147
+ } else if let phAsset = asset {
743
1148
  // ✅ 1순위: PHAsset
744
1149
  let originalUri = "ph://\(phAsset.localIdentifier)"
745
1150
 
@@ -778,8 +1183,9 @@ class RNAttachmentKit: RCTEventEmitter {
778
1183
 
779
1184
  self.sendEvent(withName: "onImageProcessed", body: [
780
1185
  "index": index,
781
- "total": selectedImages.count,
782
- "image": currentResult
1186
+ "total": total,
1187
+ "image": currentResult,
1188
+ "processingId": processingId
783
1189
  ])
784
1190
  } else {
785
1191
  // ✅ 2순위: 카메라 촬영본
@@ -828,15 +1234,31 @@ class RNAttachmentKit: RCTEventEmitter {
828
1234
 
829
1235
  self.sendEvent(withName: "onImageProcessed", body: [
830
1236
  "index": index,
831
- "total": selectedImages.count,
832
- "image": currentResult
1237
+ "total": total,
1238
+ "image": currentResult,
1239
+ "processingId": processingId
833
1240
  ])
834
1241
  }
1242
+ } // end autoreleasepool
835
1243
  } // end for
836
1244
 
837
1245
  DispatchQueue.main.async {
838
- self.currentGalleryVC = nil
839
- resolve(results)
1246
+ hud?.dismiss()
1247
+ // 처리하는 동안 새 갤러리가 열렸을 수 있음 → 내 갤러리일 때만 비움
1248
+ if self.currentGalleryVC == nil || self.currentGalleryVC === galleryVC { self.currentGalleryVC = nil }
1249
+ self.processingJobs[processingId] = nil
1250
+ exporterLock.lock(); let wasCancelled = cancelledAll; exporterLock.unlock()
1251
+ if wasCancelled {
1252
+ // 변환 취소: 이미 만든 파일은 버림
1253
+ results.compactMap { ($0["uri"] as? String)?.replacingOccurrences(of: "file://", with: "") }
1254
+ .forEach { try? FileManager.default.removeItem(atPath: $0) }
1255
+ reject("CANCELLED", "User cancelled video conversion", nil)
1256
+ } else {
1257
+ resolve(results)
1258
+ }
1259
+ // 결과를 넘긴 뒤: 갤러리의 PHCachingImageManager 캐시·콜백 해제 (닫힘 애니메이션에는 영향 없음).
1260
+ // 새 갤러리가 열려 있으면 그 화면이 미리 인코딩 중인 파일은 건드리지 않음
1261
+ galleryVC?.releaseAfterReturn(discardPreparedVideos: self.currentGalleryVC == nil)
840
1262
  }
841
1263
  }
842
1264
  }
@@ -877,16 +1299,88 @@ class RNAttachmentKit: RCTEventEmitter {
877
1299
  }
878
1300
  }
879
1301
  }
1302
+ #if DEBUG
880
1303
  print("🔵 [DEBUG] Sheet 설정 완료 - 경과: \(String(format: "%.3f", CFAbsoluteTimeGetCurrent() - step3))초")
1304
+ #endif
881
1305
 
882
1306
  let step4 = CFAbsoluteTimeGetCurrent()
883
- print("🔵 [DEBUG] present 시작...")
884
1307
  rootViewController.present(navigationController, animated: true)
1308
+ #if DEBUG
885
1309
  print("🔵 [DEBUG] present 완료 - 경과: \(String(format: "%.3f", CFAbsoluteTimeGetCurrent() - step4))초")
886
1310
  print("✅ [DEBUG] openGallery 전체 완료 - 총 경과: \(String(format: "%.3f", CFAbsoluteTimeGetCurrent() - startTime))초")
1311
+ #endif
887
1312
  }
888
1313
  }
889
1314
 
1315
+ /// 동영상 변환 취소 (HUD 없이 앱이 말풍선에서 취소할 때). index 생략(-1) = 남은 동영상 전부.
1316
+ /// 취소된 동영상은 onImageProcessed / 결과에 { error: "CANCELLED", cancelled: true } 로 오고, 나머지는 계속 처리
1317
+ /// processingId 생략("") = 가장 최근 처리
1318
+ @objc(cancelVideoProcessing:processingId:resolve:reject:)
1319
+ func cancelVideoProcessing(_ index: NSNumber, processingId: NSString, resolve: @escaping RCTPromiseResolveBlock,
1320
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
1321
+ DispatchQueue.main.async {
1322
+ let id = processingId.length > 0 ? processingId as String : self.lastProcessingId
1323
+ guard let id = id, let job = self.processingJobs[id] else { resolve(false); return }
1324
+ let i = index.intValue
1325
+ job.cancel(index: i >= 0 ? i : nil)
1326
+ resolve(true)
1327
+ }
1328
+ }
1329
+
1330
+ /// onProcessingStart 의 한 자리: 사진은 예상 결과 크기, 동영상은 예상 결과 크기·길이(구간 반영)·포스터(크롭·그리기 반영 jpg)
1331
+ private func processingStartItem(index: Int, asset: PHAsset?, image: UIImage?, videoStates: [String: VideoEditState],
1332
+ videoOptions: VideoExportOptions, maxWidth: Int, maxHeight: Int, tempDir: String) -> [String: Any] {
1333
+ if let a = asset, a.mediaType == .video {
1334
+ let state = videoStates[a.localIdentifier] ?? VideoEditState.defaultState(for: a, options: videoOptions)
1335
+ let d = CGSize(width: a.pixelWidth, height: a.pixelHeight)
1336
+ let size = VideoExporter.predictedOutputSize(displaySize: d, state: state, options: videoOptions, sourceDuration: a.duration)
1337
+ var item: [String: Any] = [
1338
+ "index": index, "mediaType": "video", "originalUri": "ph://\(a.localIdentifier)",
1339
+ "width": Int(size.width), "height": Int(size.height),
1340
+ "duration": state.outputDuration(sourceDuration: a.duration),
1341
+ ]
1342
+ if let poster = image {
1343
+ let edited = state.applyEdits(to: poster)
1344
+ if let encoded = VideoExporter.encodePoster(edited, format: videoOptions.posterFormat, quality: 0.8) {
1345
+ let path = (tempDir as NSString).appendingPathComponent("turbo_video_poster_\(Int(Date().timeIntervalSince1970 * 1000))_\(index).\(encoded.fileExtension)")
1346
+ if (try? encoded.data.write(to: URL(fileURLWithPath: path))) != nil {
1347
+ item["thumbnailUri"] = "file://" + path
1348
+ item["thumbnailWidth"] = Int(edited.size.width * edited.scale)
1349
+ item["thumbnailHeight"] = Int(edited.size.height * edited.scale)
1350
+ }
1351
+ }
1352
+ }
1353
+ return item
1354
+ }
1355
+ // 사진: 리사이즈 규칙(resizeImage)과 같은 예상 크기 (resizeImage 처럼 image.size 기준)
1356
+ var w = image?.size.width ?? CGFloat(asset?.pixelWidth ?? 0)
1357
+ var h = image?.size.height ?? CGFloat(asset?.pixelHeight ?? 0)
1358
+ if w > 0, h > 0 {
1359
+ var ratio: CGFloat = 1
1360
+ if maxWidth > 0 && maxHeight > 0 { ratio = min(CGFloat(maxWidth) / w, CGFloat(maxHeight) / h, 1) }
1361
+ else if maxWidth > 0 { ratio = min(CGFloat(maxWidth) / w, 1) }
1362
+ else if maxHeight > 0 { ratio = min(CGFloat(maxHeight) / h, 1) }
1363
+ w *= ratio; h *= ratio
1364
+ }
1365
+ var item: [String: Any] = ["index": index, "mediaType": "image", "width": Int(w.rounded()), "height": Int(h.rounded())]
1366
+ if let a = asset { item["originalUri"] = "ph://\(a.localIdentifier)" }
1367
+ return item
1368
+ }
1369
+
1370
+ /// 동영상 포스터 (긴 변 512, 동기) — 처리 시작 이벤트용
1371
+ private static func requestPoster(_ asset: PHAsset) -> UIImage? {
1372
+ let o = PHImageRequestOptions()
1373
+ o.isSynchronous = true
1374
+ o.deliveryMode = .highQualityFormat
1375
+ o.resizeMode = .fast
1376
+ o.isNetworkAccessAllowed = true
1377
+ var out: UIImage?
1378
+ PHImageManager.default().requestImage(for: asset, targetSize: CGSize(width: 512, height: 512), contentMode: .aspectFit, options: o) { img, _ in
1379
+ out = img
1380
+ }
1381
+ return out
1382
+ }
1383
+
890
1384
  @objc
891
1385
  func closeGallery(_ resolve: @escaping RCTPromiseResolveBlock,
892
1386
  reject: @escaping RCTPromiseRejectBlock) -> Void {
@@ -902,6 +1396,13 @@ class RNAttachmentKit: RCTEventEmitter {
902
1396
  return
903
1397
  }
904
1398
 
1399
+ // openGallery 약속이 아직 안 풀렸으면 (닫기 버튼과 같은 경로로) 취소를 먼저 전달 → reject("CANCELLED")
1400
+ // 안 그러면 아래 cleanupMemory 가 콜백을 지워 버려 JS 약속이 영영 안 풀렸습니다.
1401
+ if !galleryVC.hasReturnedImages {
1402
+ galleryVC.hasReturnedImages = true
1403
+ galleryVC.onImagesSelected?([])
1404
+ }
1405
+
905
1406
  // 갤러리 닫기 전 강제 정리
906
1407
  galleryVC.forceCleanupBeforeDismiss()
907
1408
 
@@ -1032,14 +1533,18 @@ class RNAttachmentKit: RCTEventEmitter {
1032
1533
  let options: [CFString: Any] = [kCGImageDestinationLossyCompressionQuality: quality]
1033
1534
  CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
1034
1535
  if CGImageDestinationFinalize(destination) {
1536
+ #if DEBUG
1035
1537
  NSLog("✅ [encodeWebP] iOS 16 네이티브 WebP 인코딩 성공 - %d bytes", data.length)
1538
+ #endif
1036
1539
  return data as Data
1037
1540
  }
1038
1541
  }
1039
1542
  }
1040
1543
  }
1041
1544
 
1545
+ #if DEBUG
1042
1546
  NSLog("🟣 [encodeWebP] SDImageWebPCoder 런타임 폴백 시도")
1547
+ #endif
1043
1548
  // iOS 14, 15이거나 네이티브 실패 시 SDImageWebPCoder 런타임 호출
1044
1549
  if let coderClass = NSClassFromString("SDImageWebPCoder") as? NSObject.Type {
1045
1550
  if coderClass.responds(to: NSSelectorFromString("sharedCoder")) {
@@ -1057,7 +1562,9 @@ class RNAttachmentKit: RCTEventEmitter {
1057
1562
  // format: 3 (SDImageFormatWebP)
1058
1563
  let options: [String: Any] = ["encodeCompressionQuality": quality]
1059
1564
  if let data = encode(sharedCoder, encodeSel, image, 3, options) {
1565
+ #if DEBUG
1060
1566
  NSLog("✅ [encodeWebP] SDImageWebPCoder 런타임 인코딩 성공 - %d bytes", data.count)
1567
+ #endif
1061
1568
  return data
1062
1569
  } else {
1063
1570
  NSLog("❌ [encodeWebP] SDImageWebPCoder 런타임 인코딩 실패 (nil 반환)")
@@ -1181,9 +1688,105 @@ class RNAttachmentKit: RCTEventEmitter {
1181
1688
  }
1182
1689
 
1183
1690
  // MARK: - Editor Result Processing
1691
+
1692
+ /// 동영상 1장을 전송용 mp4 로 만들어 resolve (갤러리 다중 전송 경로의 동영상 분기와 같은 필드)
1693
+ private func exportSingleVideo(_ phAsset: PHAsset, galleryVC: GalleryViewController?, tempDir: String,
1694
+ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
1695
+ let videoOptions = galleryVC?.videoExportOptions ?? VideoExportOptions()
1696
+ let state = galleryVC?.videoEditStates[phAsset.localIdentifier] ?? VideoEditState.defaultState(for: phAsset, options: videoOptions)
1697
+ 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
1698
+ let exporter = VideoExporter()
1699
+ let hudCancelled = AtomicFlag()
1700
+ hud?.onCancel = { hudCancelled.value = true; exporter.cancel() }
1701
+ let job = MediaProcessingJob()
1702
+ let processingId = UUID().uuidString
1703
+ processingJobs[processingId] = job
1704
+ lastProcessingId = processingId
1705
+ DispatchQueue.global(qos: .userInitiated).async {
1706
+ let poster = Self.requestPoster(phAsset)
1707
+ self.sendEvent(withName: "onProcessingStart", body: [
1708
+ "processingId": processingId,
1709
+ "total": 1,
1710
+ "items": [self.processingStartItem(index: 0, asset: phAsset, image: poster, videoStates: [phAsset.localIdentifier: state],
1711
+ videoOptions: videoOptions, maxWidth: 0, maxHeight: 0, tempDir: tempDir)],
1712
+ ])
1713
+ let timestamp = Int(Date().timeIntervalSince1970 * 1000)
1714
+ var result: [String: Any] = [
1715
+ "originalUri": "ph://\(phAsset.localIdentifier)",
1716
+ "originalWidth": phAsset.pixelWidth,
1717
+ "originalHeight": phAsset.pixelHeight,
1718
+ "mediaType": "video",
1719
+ "type": "video/mp4",
1720
+ "duration": phAsset.duration,
1721
+ ]
1722
+ do {
1723
+ job.begin(index: 0, exporter: exporter)
1724
+ let throttle = ProgressThrottle()
1725
+ let onProgress: (Double) -> Void = { p in
1726
+ guard throttle.shouldSend(p) else { return }
1727
+ DispatchQueue.main.async { hud?.update(progress: p, index: 0, total: 1) }
1728
+ self.sendEvent(withName: "onVideoProgress", body: ["index": 0, "total": 1, "progress": p, "processingId": processingId])
1729
+ }
1730
+ let r: VideoExportResult
1731
+ // HUD 취소는 exporter.cancel() — 기다리는 동안엔 job 으로 알 수 없으므로 HUD 취소도 job 에 기록
1732
+ if let ready = VideoPrepareManager.shared.takeReady(asset: phAsset, state: state, progress: onProgress,
1733
+ isCancelled: { job.isCancelled(0) || hudCancelled.value }) {
1734
+ r = ready; onProgress(1)
1735
+ } else {
1736
+ if job.isCancelled(0) || hudCancelled.value { throw VideoExporter.ExportError.cancelled }
1737
+ r = try exporter.export(asset: phAsset, state: state, options: videoOptions,
1738
+ outputDir: URL(fileURLWithPath: tempDir), baseName: "turbo_video_\(timestamp)_0", progress: onProgress)
1739
+ }
1740
+ result["uri"] = "file://" + r.url.path
1741
+ result["width"] = r.width
1742
+ result["height"] = r.height
1743
+ result["duration"] = r.duration
1744
+ result["fileSize"] = r.fileSize
1745
+ result["fileName"] = r.url.lastPathComponent
1746
+ result["fileExtension"] = "mp4"
1747
+ result["videoCodec"] = r.codec
1748
+ result["videoBitrate"] = r.bitrate
1749
+ result["edited"] = !r.passthrough
1750
+ if let t = r.thumbnailURL {
1751
+ result["thumbnailUri"] = "file://" + t.path
1752
+ result["thumbnailWidth"] = r.thumbnailWidth
1753
+ result["thumbnailHeight"] = r.thumbnailHeight
1754
+ }
1755
+ if job.isCancelled(0) { throw VideoExporter.ExportError.cancelled }
1756
+ } catch VideoExporter.ExportError.cancelled where job.isCancelled(0) {
1757
+ // 앱이 취소 (cancelVideoProcessing) — 이벤트·결과에 cancelled 로
1758
+ result["error"] = "CANCELLED"
1759
+ result["cancelled"] = true
1760
+ } catch VideoExporter.ExportError.cancelled {
1761
+ job.end()
1762
+ DispatchQueue.main.async {
1763
+ self.processingJobs[processingId] = nil
1764
+ hud?.dismiss(); reject("CANCELLED", "Video export cancelled", nil)
1765
+ }
1766
+ return
1767
+ } catch {
1768
+ result["error"] = error.localizedDescription
1769
+ }
1770
+ job.end()
1771
+ self.sendEvent(withName: "onImageProcessed", body: ["index": 0, "total": 1, "image": result, "processingId": processingId])
1772
+ DispatchQueue.main.async {
1773
+ hud?.dismiss()
1774
+ if self.currentGalleryVC == nil || self.currentGalleryVC === galleryVC { self.currentGalleryVC = nil }
1775
+ self.processingJobs[processingId] = nil
1776
+ galleryVC?.releaseAfterReturn(discardPreparedVideos: self.currentGalleryVC == nil)
1777
+ resolve([result])
1778
+ }
1779
+ }
1780
+ }
1781
+
1184
1782
 
1185
1783
  private func processEditorResult(asset: PHAsset?, croppedImage: UIImage?, filterState: FilterState, caption: String, galleryVC: GalleryViewController?, targetMaxWidth: Int? = nil, targetMaxHeight: Int? = nil, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
1186
1784
  let tempDir = prepareCacheDirectory()
1785
+ // 동영상 1장 편집 → 전송: 편집 화면에서 고른 품질·음소거로 VideoExporter (사진 경로와 같은 결과 형태)
1786
+ if let phAsset = asset, phAsset.mediaType == .video {
1787
+ exportSingleVideo(phAsset, galleryVC: galleryVC, tempDir: tempDir, resolve: resolve, reject: reject)
1788
+ return
1789
+ }
1187
1790
  let maxWidth = targetMaxWidth ?? galleryVC?.maxWidth ?? 1024
1188
1791
  let maxHeight = targetMaxHeight ?? galleryVC?.maxHeight ?? 1024
1189
1792
  let format = galleryVC?.outputFormat ?? "webp"
@@ -1237,6 +1840,7 @@ class RNAttachmentKit: RCTEventEmitter {
1237
1840
  DispatchQueue.main.async {
1238
1841
  self.currentGalleryVC = nil
1239
1842
  reject("ERROR", "Failed to load image from editor", nil)
1843
+ galleryVC?.releaseAfterReturn()
1240
1844
  }
1241
1845
  return
1242
1846
  }
@@ -1275,6 +1879,7 @@ class RNAttachmentKit: RCTEventEmitter {
1275
1879
  DispatchQueue.main.async {
1276
1880
  self.currentGalleryVC = nil
1277
1881
  resolve([result])
1882
+ galleryVC?.releaseAfterReturn()
1278
1883
  }
1279
1884
  }
1280
1885
  }
@@ -1289,10 +1894,12 @@ class RNAttachmentKit: RCTEventEmitter {
1289
1894
  DispatchQueue.global(qos: .userInitiated).async {
1290
1895
  let photoManager = PhotoManager.shared
1291
1896
  var results: [[String: Any]] = []
1292
- let dispatchGroup = DispatchGroup()
1293
1897
  let resultsLock = NSLock()
1294
1898
 
1899
+ // 한 장씩 순서대로 처리 (동시에 전부 디코딩하면 메모리가 치솟음). 해상도는 줄이지 않음
1295
1900
  for (index, asset) in assets.enumerated() {
1901
+ autoreleasepool {
1902
+ let dispatchGroup = DispatchGroup()
1296
1903
  dispatchGroup.enter()
1297
1904
  let timestamp = Int(Date().timeIntervalSince1970 * 1000)
1298
1905
  let originalUri = "ph://\(asset.localIdentifier)"
@@ -1367,10 +1974,10 @@ class RNAttachmentKit: RCTEventEmitter {
1367
1974
  }
1368
1975
  }
1369
1976
  }
1977
+ dispatchGroup.wait()
1978
+ } // end autoreleasepool
1370
1979
  }
1371
1980
 
1372
- dispatchGroup.wait()
1373
-
1374
1981
  DispatchQueue.main.async {
1375
1982
  self.currentGalleryVC = nil
1376
1983
  if results.isEmpty {
@@ -1379,6 +1986,7 @@ class RNAttachmentKit: RCTEventEmitter {
1379
1986
  // 순서를 유지하기 위해
1380
1987
  resolve(results)
1381
1988
  }
1989
+ galleryVC.releaseAfterReturn()
1382
1990
  }
1383
1991
  }
1384
1992
  }
@@ -1625,6 +2233,7 @@ fileprivate extension RNAttachmentKit {
1625
2233
 
1626
2234
  editorVC.onEditDeleted = { [weak currentGallery] asset in
1627
2235
  currentGallery?.editedImages.removeValue(forKey: asset.localIdentifier)
2236
+ currentGallery?.videoEditStates.removeValue(forKey: asset.localIdentifier) // 동영상 편집도 기본으로 돌아간 것
1628
2237
  }
1629
2238
 
1630
2239
  editorVC.modalPresentationStyle = .overFullScreen
@@ -1640,7 +2249,22 @@ fileprivate extension RNAttachmentKit {
1640
2249
  }
1641
2250
  }
1642
2251
  editorVC.croppedImages = initialCroppedImages
2252
+ // 동영상 페이지: 품질 단계·용량 상한 + 갤러리가 들고 있는 상태. 바뀌면 바로 갤러리에 반영 (전송 때 VideoExporter 가 읽음)
2253
+ editorVC.videoOptions = VideoExportOptions(dictionary: options["video"] as? [String: Any])
2254
+ var initialVideoStates: [Int: VideoEditState] = [:]
2255
+ for (i, a) in editorAssets.enumerated() {
2256
+ if let st = currentGallery.videoEditStates[a.localIdentifier] { initialVideoStates[i] = st }
2257
+ }
2258
+ editorVC.videoEditStates = initialVideoStates
2259
+ editorVC.onVideoStateChanged = { [weak currentGallery] asset, state in
2260
+ currentGallery?.videoEditStates[asset.localIdentifier] = state
2261
+ }
1643
2262
 
2263
+ if sourceFrame == .zero {
2264
+ // 기준 칸이 없을 때(칸이 화면 밖 등): 기본 전환(아래로 미끄러짐)은 끌어내려 닫을 때 위로 치워 둔 상단 바가
2265
+ // 화면과 함께 내려오며 보이므로 페이드로
2266
+ editorVC.modalTransitionStyle = .crossDissolve
2267
+ }
1644
2268
  if sourceFrame != .zero {
1645
2269
  self.editorTransitionDelegate = ImageEditorTransitionDelegate()
1646
2270
  self.editorTransitionDelegate?.sourceFrame = sourceFrame
@@ -1674,10 +2298,16 @@ fileprivate extension RNAttachmentKit {
1674
2298
  self.editorTransitionDelegate?.onCurrentAssetChanged = { [weak currentGallery] currentAsset in
1675
2299
  currentGallery?.setHiddenAsset(currentAsset, scrollIntoView: true)
1676
2300
  }
1677
- self.editorTransitionDelegate?.onEditorClosed = { [weak currentGallery] in
2301
+ self.editorTransitionDelegate?.onEditorClosed = { [weak self, weak currentGallery] in
1678
2302
  currentGallery?.setHiddenAsset(nil)
2303
+ // 편집 화면이 닫혔으니 sourceImage/uncroppedImage 를 들고 있는 전환 델리게이트를 놓아 줌
2304
+ // (호출 중인 객체이므로 다음 런루프에서)
2305
+ DispatchQueue.main.async { self?.editorTransitionDelegate = nil }
2306
+ }
2307
+ // 칸은 열기 애니메이션의 첫 프레임에 비움 (누르자마자 비우면 편집 화면이 준비되는 동안 빈 칸이 번쩍임)
2308
+ self.editorTransitionDelegate?.onPresentationStarted = { [weak currentGallery] in
2309
+ currentGallery?.setHiddenAsset(asset)
1679
2310
  }
1680
- currentGallery.setHiddenAsset(asset)
1681
2311
 
1682
2312
  editorVC.transitioningDelegate = self.editorTransitionDelegate
1683
2313
  }
@@ -1686,10 +2316,10 @@ fileprivate extension RNAttachmentKit {
1686
2316
  editorVC.themeColor = color
1687
2317
  }
1688
2318
 
1689
- editorVC.onConfirmMulti = { [weak currentGallery] (assets, croppedImages, filterStates) in
2319
+ editorVC.onConfirmMulti = { [weak currentGallery, weak editorVC] (assets, croppedImages, filterStates) in
1690
2320
  guard let currentGallery = currentGallery else { return }
1691
2321
 
1692
- editorVC.dismiss(animated: true) {
2322
+ editorVC?.dismiss(animated: true) {
1693
2323
  DispatchQueue.global(qos: .userInitiated).async {
1694
2324
  var newlyEditedAssets: [PHAsset] = []
1695
2325
 
@@ -1731,7 +2361,7 @@ fileprivate extension RNAttachmentKit {
1731
2361
  }
1732
2362
 
1733
2363
  // patch398: 편집 화면의 전송 버튼 — 편집본을 갤러리에 반영하고 선택한 사진을 바로 전송(편집 화면/갤러리 모두 닫힘)
1734
- editorVC.onSendMulti = { [weak currentGallery] (assets, croppedImages, filterStates) in
2364
+ editorVC.onSendMulti = { [weak currentGallery, weak editorVC] (assets, croppedImages, filterStates) in
1735
2365
  guard let currentGallery = currentGallery else { return }
1736
2366
  for asset in assets {
1737
2367
  guard var img = croppedImages[asset] else { continue }
@@ -1748,7 +2378,7 @@ fileprivate extension RNAttachmentKit {
1748
2378
  // patch399: 편집 화면의 이미지가 갤러리 자리로 돌아가는 애니메이션이 끝난 뒤에 전송합니다.
1749
2379
  currentGallery.refreshThumbnails(for: assets)
1750
2380
  currentGallery.updateSelectedCellNumbers()
1751
- editorVC.dismiss(animated: true) { [weak currentGallery] in
2381
+ editorVC?.dismiss(animated: true) { [weak currentGallery] in
1752
2382
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
1753
2383
  currentGallery?.sendSelectionFromEditor()
1754
2384
  }
@@ -1801,3 +2431,69 @@ extension RNAttachmentKit: UIViewControllerTransitioningDelegate {
1801
2431
  return zoomAnimator
1802
2432
  }
1803
2433
  }
2434
+
2435
+ // MARK: - 전송 처리 상태 (앱에서 동영상 취소)
2436
+
2437
+ /// openGallery 결과 처리 한 번. cancelVideoProcessing(index) → 그 동영상만(또는 전부) 취소
2438
+ private final class MediaProcessingJob {
2439
+ private let lock = NSLock()
2440
+ private var cancelAll = false
2441
+ private var cancelled = Set<Int>()
2442
+ private var currentIndex: Int?
2443
+ private var currentExporter: VideoExporter?
2444
+
2445
+ func cancel(index: Int?) {
2446
+ lock.lock(); defer { lock.unlock() }
2447
+ if let i = index {
2448
+ cancelled.insert(i)
2449
+ if currentIndex == i { currentExporter?.cancel() }
2450
+ } else {
2451
+ cancelAll = true
2452
+ currentExporter?.cancel()
2453
+ }
2454
+ }
2455
+
2456
+ func isCancelled(_ i: Int) -> Bool {
2457
+ lock.lock(); defer { lock.unlock() }
2458
+ return cancelAll || cancelled.contains(i)
2459
+ }
2460
+
2461
+ func begin(index: Int, exporter: VideoExporter) {
2462
+ lock.lock(); defer { lock.unlock() }
2463
+ currentIndex = index
2464
+ currentExporter = exporter
2465
+ }
2466
+
2467
+ func end() {
2468
+ lock.lock(); defer { lock.unlock() }
2469
+ currentIndex = nil
2470
+ currentExporter = nil
2471
+ }
2472
+ }
2473
+
2474
+ /// onVideoProgress 를 1% 이상 바뀌고 0.1초 지났을 때만 (브릿지가 넘치지 않게). 0 과 1 은 항상
2475
+ private final class ProgressThrottle {
2476
+ private let lock = NSLock()
2477
+ private var last: Double = -1
2478
+ private var lastTime: CFTimeInterval = 0
2479
+
2480
+ func shouldSend(_ p: Double) -> Bool {
2481
+ lock.lock(); defer { lock.unlock() }
2482
+ let now = CACurrentMediaTime()
2483
+ guard p >= 1 || last < 0 || (p - last >= 0.01 && now - lastTime >= 0.1) else { return false }
2484
+ if p >= 1 && last >= 1 { return false }
2485
+ last = p
2486
+ lastTime = now
2487
+ return true
2488
+ }
2489
+ }
2490
+
2491
+ /// 스레드 사이에서 읽고 쓰는 Bool
2492
+ private final class AtomicFlag {
2493
+ private let lock = NSLock()
2494
+ private var v = false
2495
+ var value: Bool {
2496
+ get { lock.lock(); defer { lock.unlock() }; return v }
2497
+ set { lock.lock(); v = newValue; lock.unlock() }
2498
+ }
2499
+ }