react-native-compressor 1.18.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/android/CMakeLists.txt +27 -0
  2. package/android/build.gradle +56 -58
  3. package/android/gradle.properties +5 -5
  4. package/android/src/main/AndroidManifestNew.xml +3 -0
  5. package/android/src/main/cpp/cpp-adapter.cpp +8 -0
  6. package/android/src/main/java/com/margelo/nitro/compressor/HybridCompressor.kt +213 -0
  7. package/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt +41 -21
  8. package/android/src/main/java/com/reactnativecompressor/NitroCompressorPackage.kt +30 -0
  9. package/android/src/main/java/com/reactnativecompressor/NitroPromiseAdapter.kt +55 -0
  10. package/android/src/main/java/com/reactnativecompressor/Utils/EventEmitterHandler.kt +49 -45
  11. package/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt +1 -1
  12. package/android/src/main/java/com/reactnativecompressor/Video/AutoVideoCompression.kt +1 -1
  13. package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressionProfile.kt +21 -11
  14. package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt +311 -76
  15. package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/CompressorUtils.kt +96 -5
  16. package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/LocationExtractor.kt +397 -0
  17. package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/LocationBox.kt +47 -0
  18. package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/MP4Builder.kt +39 -7
  19. package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/Mp4Movie.kt +7 -0
  20. package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/OutputSurface.kt +30 -4
  21. package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressorHelper.kt +28 -2
  22. package/ios/Audio/FormatConverter/FormatConverter+Compressed.swift +1 -1
  23. package/ios/Audio/FormatConverter/FormatConverter+Utilities.swift +1 -1
  24. package/ios/Audio/FormatConverter/FormatConverter.swift +5 -3
  25. package/ios/HybridCompressor.swift +247 -0
  26. package/ios/Image/ImageCompressor.swift +8 -2
  27. package/ios/Utils/EventEmitterHandler.swift +56 -34
  28. package/ios/Video/NextLevelSessionExporter.swift +43 -8
  29. package/ios/Video/VideoMain.swift +44 -14
  30. package/lib/commonjs/Audio/index.js +1 -1
  31. package/lib/commonjs/Audio/index.js.map +1 -1
  32. package/lib/commonjs/Image/index.js +2 -22
  33. package/lib/commonjs/Image/index.js.map +1 -1
  34. package/lib/commonjs/Main.js +8 -8
  35. package/lib/commonjs/Main.js.map +1 -1
  36. package/lib/commonjs/Video/index.js +25 -65
  37. package/lib/commonjs/Video/index.js.map +1 -1
  38. package/lib/commonjs/specs/Compressor.nitro.js +6 -0
  39. package/lib/commonjs/specs/Compressor.nitro.js.map +1 -0
  40. package/lib/commonjs/utils/Downloader.js +8 -24
  41. package/lib/commonjs/utils/Downloader.js.map +1 -1
  42. package/lib/commonjs/utils/Uploader.js +3 -18
  43. package/lib/commonjs/utils/Uploader.js.map +1 -1
  44. package/lib/commonjs/utils/helpers.js +18 -1
  45. package/lib/commonjs/utils/helpers.js.map +1 -1
  46. package/lib/commonjs/utils/index.js +13 -13
  47. package/lib/commonjs/utils/index.js.map +1 -1
  48. package/lib/module/Audio/index.js +2 -2
  49. package/lib/module/Audio/index.js.map +1 -1
  50. package/lib/module/Image/index.js +3 -23
  51. package/lib/module/Image/index.js.map +1 -1
  52. package/lib/module/Main.js +9 -9
  53. package/lib/module/Main.js.map +1 -1
  54. package/lib/module/Video/index.js +26 -66
  55. package/lib/module/Video/index.js.map +1 -1
  56. package/lib/module/specs/Compressor.nitro.js +4 -0
  57. package/lib/module/specs/Compressor.nitro.js.map +1 -0
  58. package/lib/module/utils/Downloader.js +10 -26
  59. package/lib/module/utils/Downloader.js.map +1 -1
  60. package/lib/module/utils/Uploader.js +5 -20
  61. package/lib/module/utils/Uploader.js.map +1 -1
  62. package/lib/module/utils/helpers.js +16 -0
  63. package/lib/module/utils/helpers.js.map +1 -1
  64. package/lib/module/utils/index.js +2 -1
  65. package/lib/module/utils/index.js.map +1 -1
  66. package/lib/typescript/src/Image/index.d.ts.map +1 -1
  67. package/lib/typescript/src/Main.d.ts +2 -1
  68. package/lib/typescript/src/Main.d.ts.map +1 -1
  69. package/lib/typescript/src/Video/index.d.ts +1 -1
  70. package/lib/typescript/src/Video/index.d.ts.map +1 -1
  71. package/lib/typescript/src/index.d.ts.map +1 -1
  72. package/lib/typescript/src/specs/Compressor.nitro.d.ts +44 -0
  73. package/lib/typescript/src/specs/Compressor.nitro.d.ts.map +1 -0
  74. package/lib/typescript/src/utils/Downloader.d.ts.map +1 -1
  75. package/lib/typescript/src/utils/Uploader.d.ts.map +1 -1
  76. package/lib/typescript/src/utils/helpers.d.ts +7 -0
  77. package/lib/typescript/src/utils/helpers.d.ts.map +1 -1
  78. package/lib/typescript/src/utils/index.d.ts.map +1 -1
  79. package/nitrogen/generated/android/NitroCompressor+autolinking.cmake +81 -0
  80. package/nitrogen/generated/android/NitroCompressor+autolinking.gradle +27 -0
  81. package/nitrogen/generated/android/NitroCompressorOnLoad.cpp +60 -0
  82. package/nitrogen/generated/android/NitroCompressorOnLoad.hpp +34 -0
  83. package/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
  84. package/nitrogen/generated/android/c++/JFunc_void_double.hpp +75 -0
  85. package/nitrogen/generated/android/c++/JFunc_void_double_double.hpp +75 -0
  86. package/nitrogen/generated/android/c++/JHybridCompressorSpec.cpp +293 -0
  87. package/nitrogen/generated/android/c++/JHybridCompressorSpec.hpp +78 -0
  88. package/nitrogen/generated/android/c++/JVideoThumbnailResult.hpp +73 -0
  89. package/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/Func_void.kt +80 -0
  90. package/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/Func_void_double.kt +80 -0
  91. package/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/Func_void_double_double.kt +80 -0
  92. package/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/HybridCompressorSpec.kt +141 -0
  93. package/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/NitroCompressorOnLoad.kt +35 -0
  94. package/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/VideoThumbnailResult.kt +71 -0
  95. package/nitrogen/generated/ios/c++/HybridCompressorSpecSwift.cpp +11 -0
  96. package/nitrogen/generated/ios/c++/HybridCompressorSpecSwift.hpp +204 -0
  97. package/nitrogen/generated/ios/react_native_compressor+autolinking.rb +62 -0
  98. package/nitrogen/generated/ios/react_native_compressor-Swift-Cxx-Bridge.cpp +89 -0
  99. package/nitrogen/generated/ios/react_native_compressor-Swift-Cxx-Bridge.hpp +337 -0
  100. package/nitrogen/generated/ios/react_native_compressor-Swift-Cxx-Umbrella.hpp +51 -0
  101. package/nitrogen/generated/ios/react_native_compressorAutolinking.mm +33 -0
  102. package/nitrogen/generated/ios/react_native_compressorAutolinking.swift +26 -0
  103. package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
  104. package/nitrogen/generated/ios/swift/Func_void_VideoThumbnailResult.swift +46 -0
  105. package/nitrogen/generated/ios/swift/Func_void_double.swift +46 -0
  106. package/nitrogen/generated/ios/swift/Func_void_double_double.swift +46 -0
  107. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  108. package/nitrogen/generated/ios/swift/Func_void_std__shared_ptr_AnyMap_.swift +46 -0
  109. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  110. package/nitrogen/generated/ios/swift/HybridCompressorSpec.swift +70 -0
  111. package/nitrogen/generated/ios/swift/HybridCompressorSpec_cxx.swift +493 -0
  112. package/nitrogen/generated/ios/swift/VideoThumbnailResult.swift +49 -0
  113. package/nitrogen/generated/shared/c++/HybridCompressorSpec.cpp +36 -0
  114. package/nitrogen/generated/shared/c++/HybridCompressorSpec.hpp +83 -0
  115. package/nitrogen/generated/shared/c++/VideoThumbnailResult.hpp +99 -0
  116. package/package.json +8 -12
  117. package/react-native-compressor.podspec +6 -24
  118. package/src/Audio/index.tsx +2 -2
  119. package/src/Image/index.tsx +3 -26
  120. package/src/Main.tsx +14 -16
  121. package/src/Video/index.tsx +25 -77
  122. package/src/specs/Compressor.nitro.ts +56 -0
  123. package/src/utils/Downloader.tsx +8 -27
  124. package/src/utils/Uploader.tsx +12 -24
  125. package/src/utils/helpers.ts +18 -0
  126. package/src/utils/index.tsx +3 -2
  127. package/android/src/main/java/com/reactnativecompressor/CompressorModule.kt +0 -180
  128. package/android/src/main/java/com/reactnativecompressor/CompressorPackage.kt +0 -34
  129. package/android/src/newarch/CompressorSpec.kt +0 -5
  130. package/android/src/oldarch/CompressorSpec.kt +0 -36
  131. package/ios/Compressor-Bridging-Header.h +0 -6
  132. package/ios/Compressor.h +0 -5
  133. package/ios/Compressor.mm +0 -82
  134. package/ios/CompressorManager.swift +0 -117
  135. package/lib/commonjs/Spec/NativeCompressor.js +0 -9
  136. package/lib/commonjs/Spec/NativeCompressor.js.map +0 -1
  137. package/lib/module/Spec/NativeCompressor.js +0 -5
  138. package/lib/module/Spec/NativeCompressor.js.map +0 -1
  139. package/lib/typescript/src/Spec/NativeCompressor.d.ts +0 -30
  140. package/lib/typescript/src/Spec/NativeCompressor.d.ts.map +0 -1
  141. package/src/Spec/NativeCompressor.ts +0 -41
@@ -2,6 +2,8 @@ package com.reactnativecompressor.Video.VideoCompressor.video
2
2
 
3
3
  import android.graphics.SurfaceTexture
4
4
  import android.graphics.SurfaceTexture.OnFrameAvailableListener
5
+ import android.os.Handler
6
+ import android.os.HandlerThread
5
7
  import android.view.Surface
6
8
 
7
9
  class OutputSurface : OnFrameAvailableListener {
@@ -12,6 +14,15 @@ class OutputSurface : OnFrameAvailableListener {
12
14
  private var mFrameAvailable = false
13
15
  private var mTextureRender: TextureRenderer? = null
14
16
 
17
+ // Dedicated thread for SurfaceTexture's onFrameAvailable callback.
18
+ // Without this, Android delivers the callback on the main UI thread
19
+ // (because the compression coroutine has no Looper), so awaitNewImage()
20
+ // stalls whenever the main thread is busy with UI / JS bridge work.
21
+ // Routing the callback to its own thread removes that contention and
22
+ // is the single biggest throughput win for the decoder→encoder pipeline.
23
+ private val mCallbackThread = HandlerThread("CompressorSurfaceTexCb").apply { start() }
24
+ private val mCallbackHandler = Handler(mCallbackThread.looper)
25
+
15
26
  /**
16
27
  * Creates an OutputSurface using the current EGL context. This Surface will be
17
28
  * passed to MediaCodec.configure().
@@ -35,7 +46,7 @@ class OutputSurface : OnFrameAvailableListener {
35
46
  // causes the native finalizer to run.
36
47
  mSurfaceTexture = SurfaceTexture(it.getTextureId())
37
48
  mSurfaceTexture?.let { surfaceTexture ->
38
- surfaceTexture.setOnFrameAvailableListener(this)
49
+ surfaceTexture.setOnFrameAvailableListener(this, mCallbackHandler)
39
50
  mSurface = Surface(mSurfaceTexture)
40
51
  }
41
52
  }
@@ -43,6 +54,13 @@ class OutputSurface : OnFrameAvailableListener {
43
54
 
44
55
  /**
45
56
  * Discards all resources held by this class, notably the EGL context.
57
+ *
58
+ * quitSafely() returns immediately; the HandlerThread's native pthread
59
+ * may still be terminating when callers proceed to tear down MediaCodec.
60
+ * If the ART sampling profiler walks threads during that window it can
61
+ * dereference a stale pthread_t and SIGABRT. join(500) blocks until the
62
+ * thread is fully exited (pthread_join) so the pthread_t is no longer
63
+ * tracked. Bounded at 500ms to avoid hanging on a pathological looper.
46
64
  */
47
65
  fun release() {
48
66
  mSurface?.release()
@@ -50,6 +68,13 @@ class OutputSurface : OnFrameAvailableListener {
50
68
  mTextureRender = null
51
69
  mSurface = null
52
70
  mSurfaceTexture = null
71
+
72
+ mCallbackThread.quitSafely()
73
+ try {
74
+ mCallbackThread.join(500)
75
+ } catch (ignored: InterruptedException) {
76
+ Thread.currentThread().interrupt()
77
+ }
53
78
  }
54
79
 
55
80
  /**
@@ -63,12 +88,13 @@ class OutputSurface : OnFrameAvailableListener {
63
88
  * data is available.
64
89
  */
65
90
  fun awaitNewImage() {
66
- val timeOutMS = 100
91
+ // 10s timeout to avoid spurious failures under heavy main-thread load.
92
+ // The callback now arrives on a dedicated thread, so realistic frames
93
+ // land in <50ms; this bound only catches a stuck pipeline.
94
+ val timeOutMS = 10_000
67
95
  synchronized(mFrameSyncObject) {
68
96
  while (!mFrameAvailable) {
69
97
  try {
70
- // Wait for onFrameAvailable() to signal us. Use a timeout to avoid
71
- // stalling the test if it doesn't arrive.
72
98
  mFrameSyncObject.wait(timeOutMS.toLong())
73
99
  if (!mFrameAvailable) {
74
100
  throw RuntimeException("Surface frame wait timed out")
@@ -5,6 +5,7 @@ import android.content.Context
5
5
  import android.media.MediaMetadataRetriever
6
6
  import android.net.Uri
7
7
  import android.os.Handler
8
+ import android.os.Looper
8
9
  import android.os.PowerManager
9
10
  import android.os.PowerManager.WakeLock
10
11
  import com.facebook.react.bridge.LifecycleEventListener
@@ -58,7 +59,9 @@ class VideoCompressorHelper {
58
59
  if (!wakeLock!!.isHeld()) {
59
60
  wakeLock!!.acquire()
60
61
  }
61
- handler = Handler()
62
+ // Bind to the main Looper: under Nitro this runs on a background executor
63
+ // thread (no Looper), so the no-arg Handler() constructor would throw.
64
+ handler = Handler(Looper.getMainLooper())
62
65
  runnable = Runnable { }
63
66
  handler!!.post(runnable!!)
64
67
  return ""
@@ -100,6 +103,29 @@ class VideoCompressorHelper {
100
103
  ?: 0
101
104
  }
102
105
 
106
+ /**
107
+ * Derive the source video frame rate. METADATA_KEY_CAPTURE_FRAMERATE
108
+ * is only populated for slow-motion captures, so most regular videos
109
+ * return 0 and downstream code falls back to a hard-coded 30 fps —
110
+ * which silently halves the frame count of any 60 fps source and
111
+ * produces visibly choppy output.
112
+ *
113
+ * Strategy: trust CAPTURE_FRAMERATE when present, otherwise compute
114
+ * fps from frame count / duration (API 28+ exposes the frame count
115
+ * via METADATA_KEY_VIDEO_FRAME_COUNT). Returns 0 if neither path
116
+ * yields a usable value.
117
+ */
118
+ fun getSourceFrameRate(metaRetriever: MediaMetadataRetriever): Int {
119
+ val capture = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)
120
+ if (capture > 0) return capture
121
+
122
+ val frameCount = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT)
123
+ val durationMs = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_DURATION)
124
+ if (frameCount <= 0 || durationMs <= 0) return 0
125
+ val fps = (frameCount.toLong() * 1000L / durationMs.toLong()).toInt()
126
+ return fps.coerceIn(0, 240)
127
+ }
128
+
103
129
  fun VideoCompressManual(fileUrl: String?, options: VideoCompressorHelper, promise: Promise, reactContext: ReactApplicationContext?) {
104
130
  try {
105
131
  val uri = Uri.parse(fileUrl)
@@ -110,7 +136,7 @@ class VideoCompressorHelper {
110
136
  val height = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
111
137
  val width = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
112
138
  val bitrate = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_BITRATE)
113
- val frameRate = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)
139
+ val frameRate = getSourceFrameRate(metaRetriever)
114
140
  if (height <= 0 || width <= 0) {
115
141
  promise.reject(Throwable("Failed to read the input video dimensions"))
116
142
  return
@@ -5,7 +5,7 @@ import AVFoundation
5
5
  // MARK: - internal helper functions
6
6
 
7
7
 
8
- public extension AVURLAsset {
8
+ extension AVURLAsset {
9
9
  /// Audio format for the file in the URL asset
10
10
  var audioFormat: AVAudioFormat? {
11
11
  // pull the input format out of the audio file...
@@ -11,7 +11,7 @@ extension FormatConverter {
11
11
  }
12
12
  }
13
13
 
14
- public extension FormatConverter {
14
+ extension FormatConverter {
15
15
  /// Is this file a PCM file?
16
16
  /// - Parameters:
17
17
  /// - url: The URL to parse
@@ -19,7 +19,9 @@ import AVFoundation
19
19
  }
20
20
  ```
21
21
  */
22
- public class FormatConverter {
22
+ // `internal` (not `public`): keeps these types out of the Swift↔C++ interop surface
23
+ // that Nitro enables module-wide, which otherwise fails to link the nested `Options` type.
24
+ class FormatConverter {
23
25
  // MARK: - properties
24
26
 
25
27
  /// The source audio file
@@ -122,7 +124,7 @@ public class FormatConverter {
122
124
 
123
125
  // MARK: - Definitions
124
126
 
125
- public enum AudioFileFormat: String {
127
+ enum AudioFileFormat: String {
126
128
  case aac
127
129
  case aif
128
130
  case aifc
@@ -141,7 +143,7 @@ public enum AudioFileFormat: String {
141
143
  case wav
142
144
  }
143
145
 
144
- public extension FormatConverter {
146
+ extension FormatConverter {
145
147
 
146
148
  /// FormatConverterCallback is the callback format for start()
147
149
  /// - Parameter: error This will contain one parameter of type Error which is nil if the conversion was successful.
@@ -0,0 +1,247 @@
1
+ //
2
+ // HybridCompressor.swift
3
+ // react-native-compressor
4
+ //
5
+ // Nitro HybridObject implementation of the single `Compressor` native module.
6
+ // It is a thin binding layer: it converts Nitro's `AnyMap` options into the
7
+ // `NSDictionary`/`[String: Any]` the existing domain code already consumes,
8
+ // bridges the Nitro `Promise` to the domain layer's `RCTPromiseResolveBlock`/
9
+ // `RCTPromiseRejectBlock`, and registers progress callbacks (keyed by `uuid`)
10
+ // with `EventEmitterHandler`. All heavy logic stays in the domain classes.
11
+ //
12
+
13
+ import Foundation
14
+ import NitroModules
15
+
16
+ // Previously imported from React. We define them locally so this Swift module no
17
+ // longer needs to `import React`: under Nitro's Swift↔C++ interop, importing
18
+ // React pulls move-only C++ types (e.g. jsinspector's RuntimeSamplingProfile)
19
+ // into Swift's importer and fails to compile. These blocks are created here and
20
+ // consumed by the domain layer entirely in Swift, so plain closures suffice.
21
+ typealias RCTPromiseResolveBlock = (Any?) -> Void
22
+ typealias RCTPromiseRejectBlock = (String?, String?, (any Error)?) -> Void
23
+
24
+ private let videoCompressor = VideoCompressor()
25
+ private let uploader = Uploader()
26
+
27
+ final class HybridCompressor: HybridCompressorSpec {
28
+ // MARK: - Image
29
+
30
+ func image_compress(imagePath: String, optionMap: AnyMap, onDownloadProgress: ((Double) -> Void)?) throws -> Promise<String> {
31
+ let promise = Promise<String>()
32
+ var options = dictionary(from: optionMap)
33
+ // Remote-image download progress is keyed by `uuid` inside the Downloader. The JS
34
+ // layer no longer sends one (the callback is passed directly), so mint one here.
35
+ let uuid = (options["uuid"] as? String) ?? UUID().uuidString
36
+ options["uuid"] = uuid
37
+ EventEmitterHandler.registerDownloadProgress(uuid: uuid, onDownloadProgress)
38
+ ImageMain.image_compress(imagePath, optionMap: options as NSDictionary, resolve: resolveString(promise, uuid), reject: reject(promise, uuid))
39
+ return promise
40
+ }
41
+
42
+ func getImageMetaData(filePath: String) throws -> Promise<AnyMap> {
43
+ let promise = Promise<AnyMap>()
44
+ ImageMain.getImageMetaData(filePath, resolve: resolveAnyMap(promise, nil), reject: reject(promise, nil))
45
+ return promise
46
+ }
47
+
48
+ // MARK: - Video
49
+
50
+ func compress(fileUrl: String, optionMap: AnyMap, onProgress: ((Double) -> Void)?, onDownloadProgress: ((Double) -> Void)?) throws -> Promise<String> {
51
+ let promise = Promise<String>()
52
+ let options = dictionary(from: optionMap)
53
+ let uuid = (options["uuid"] as? String) ?? ""
54
+ EventEmitterHandler.registerVideoCompressProgress(uuid: uuid, onProgress)
55
+ EventEmitterHandler.registerDownloadProgress(uuid: uuid, onDownloadProgress)
56
+ videoCompressor.compress(fileUrl: fileUrl, options: options, resolve: resolveString(promise, uuid), reject: reject(promise, uuid))
57
+ return promise
58
+ }
59
+
60
+ func cancelCompression(uuid: String) throws {
61
+ videoCompressor.cancelCompression(uuid: uuid)
62
+ }
63
+
64
+ func getVideoMetaData(filePath: String) throws -> Promise<AnyMap> {
65
+ let promise = Promise<AnyMap>()
66
+ videoCompressor.getVideoMetaData(filePath, resolve: resolveAnyMap(promise, nil), reject: reject(promise, nil))
67
+ return promise
68
+ }
69
+
70
+ func activateBackgroundTask(options: AnyMap, onExpired: (() -> Void)?) throws -> Promise<String> {
71
+ let promise = Promise<String>()
72
+ EventEmitterHandler.setBackgroundTaskExpiredCallback(onExpired)
73
+ videoCompressor.activateBackgroundTask(options: dictionary(from: options), resolve: resolveString(promise, nil), reject: reject(promise, nil))
74
+ return promise
75
+ }
76
+
77
+ func deactivateBackgroundTask(options: AnyMap) throws -> Promise<String> {
78
+ let promise = Promise<String>()
79
+ EventEmitterHandler.setBackgroundTaskExpiredCallback(nil)
80
+ videoCompressor.deactivateBackgroundTask(options: dictionary(from: options), resolve: resolveString(promise, nil), reject: reject(promise, nil))
81
+ return promise
82
+ }
83
+
84
+ // MARK: - Audio
85
+
86
+ func compress_audio(fileUrl: String, optionMap: AnyMap) throws -> Promise<String> {
87
+ let promise = Promise<String>()
88
+ AudioMain.compress_audio(fileUrl, optionMap: dictionary(from: optionMap) as NSDictionary, resolve: resolveString(promise, nil), reject: reject(promise, nil))
89
+ return promise
90
+ }
91
+
92
+ // MARK: - Upload / Download
93
+
94
+ func upload(fileUrl: String, options: AnyMap, onProgress: ((Double, Double) -> Void)?) throws -> Promise<AnyMap> {
95
+ let promise = Promise<AnyMap>()
96
+ let dict = dictionary(from: options)
97
+ let uuid = (dict["uuid"] as? String) ?? ""
98
+ EventEmitterHandler.registerUploadProgress(uuid: uuid, onProgress)
99
+ uploader.upload(filePath: fileUrl, options: dict, resolve: resolveAnyMap(promise, uuid), reject: reject(promise, uuid))
100
+ return promise
101
+ }
102
+
103
+ func cancelUpload(uuid: String, shouldCancelAll: Bool) throws {
104
+ uploader.cancelUpload(uuid: uuid, shouldCancelAll: shouldCancelAll)
105
+ }
106
+
107
+ func download(fileUrl: String, options: AnyMap, onProgress: ((Double) -> Void)?) throws -> Promise<String> {
108
+ let promise = Promise<String>()
109
+ let dict = dictionary(from: options)
110
+ let uuid = (dict["uuid"] as? String) ?? ""
111
+ let progressDivider = (dict["progressDivider"] as? NSNumber)?.intValue ?? 0
112
+ EventEmitterHandler.registerDownloadProgress(uuid: uuid, onProgress)
113
+ Downloader.downloadFileAndSaveToCache(fileUrl, uuid: uuid, progressDivider: progressDivider) { downloadedPath in
114
+ EventEmitterHandler.unregister(uuid: uuid)
115
+ promise.resolve(withResult: downloadedPath)
116
+ }
117
+ return promise
118
+ }
119
+
120
+ // MARK: - Others
121
+
122
+ func generateFilePath(fileExtension: String) throws -> Promise<String> {
123
+ let promise = Promise<String>()
124
+ Utils.generateFilePath(fileExtension, resolve: resolveString(promise, nil), reject: reject(promise, nil))
125
+ return promise
126
+ }
127
+
128
+ func getRealPath(path: String, type: String) throws -> Promise<String> {
129
+ let promise = Promise<String>()
130
+ Utils.getRealPath(path, type: type, resolve: resolveString(promise, nil), reject: reject(promise, nil))
131
+ return promise
132
+ }
133
+
134
+ func getFileSize(filePath: String) throws -> Promise<String> {
135
+ let promise = Promise<String>()
136
+ Utils.getFileSize(filePath, resolve: resolveString(promise, nil), reject: reject(promise, nil))
137
+ return promise
138
+ }
139
+
140
+ func createVideoThumbnail(fileUrl: String, options: AnyMap) throws -> Promise<VideoThumbnailResult> {
141
+ let promise = Promise<VideoThumbnailResult>()
142
+ let thumbnail = CreateVideoThumbnail()
143
+ thumbnail.create(fileUrl, options: dictionary(from: options) as NSDictionary, resolve: { value in
144
+ let dict = HybridCompressor.stringKeyedDictionary(value)
145
+ let result = VideoThumbnailResult(
146
+ path: dict["path"] as? String ?? "",
147
+ size: HybridCompressor.doubleValue(dict["size"]),
148
+ mime: dict["mime"] as? String ?? "",
149
+ width: HybridCompressor.doubleValue(dict["width"]),
150
+ height: HybridCompressor.doubleValue(dict["height"])
151
+ )
152
+ promise.resolve(withResult: result)
153
+ }, rejecter: reject(promise, nil))
154
+ return promise
155
+ }
156
+
157
+ func clearCache(cacheDir: String?) throws -> Promise<String> {
158
+ let promise = Promise<String>()
159
+ CreateVideoThumbnail.cleanCacheDir(cacheDir: cacheDir ?? "", resolve: resolveString(promise, nil), rejecter: reject(promise, nil))
160
+ return promise
161
+ }
162
+
163
+ // MARK: - Helpers
164
+
165
+ /// Convert a Nitro `AnyMap` to a `[String: Any]`, round-tripping through
166
+ /// `NSDictionary` so numbers are boxed as `NSNumber` (the domain parsers rely
167
+ /// on `as? Int` / `as? Bool`, which fail on a bare Swift `Double`).
168
+ private func dictionary(from map: AnyMap) -> [String: Any] {
169
+ let normalized = (HybridCompressor.normalize(map.toDictionary()) as? [String: Any]) ?? [:]
170
+ return ((normalized as NSDictionary) as? [String: Any]) ?? normalized
171
+ }
172
+
173
+ private func resolveString(_ promise: Promise<String>, _ uuid: String?) -> RCTPromiseResolveBlock {
174
+ return { value in
175
+ if let uuid = uuid { EventEmitterHandler.unregister(uuid: uuid) }
176
+ promise.resolve(withResult: value as? String ?? "")
177
+ }
178
+ }
179
+
180
+ private func resolveAnyMap(_ promise: Promise<AnyMap>, _ uuid: String?) -> RCTPromiseResolveBlock {
181
+ return { value in
182
+ if let uuid = uuid { EventEmitterHandler.unregister(uuid: uuid) }
183
+ promise.resolve(withResult: AnyMap.fromDictionaryIgnoreIncompatible(HybridCompressor.stringKeyedOptionalDictionary(value)))
184
+ }
185
+ }
186
+
187
+ private func reject<T>(_ promise: Promise<T>, _ uuid: String?) -> RCTPromiseRejectBlock {
188
+ return { code, message, error in
189
+ if let uuid = uuid { EventEmitterHandler.unregister(uuid: uuid) }
190
+ let nsError = error ?? RuntimeError.error(withMessage: message ?? code ?? "react-native-compressor error")
191
+ promise.reject(withError: nsError)
192
+ }
193
+ }
194
+
195
+ /// Recursively strip optionals so a `[String: Any?]` (what `AnyMap.toDictionary()`
196
+ /// returns, including nested objects/arrays) becomes a plain `[String: Any]`.
197
+ private static func normalize(_ value: Any?) -> Any? {
198
+ switch value {
199
+ case let dict as [String: Any?]:
200
+ var out = [String: Any]()
201
+ for (key, nested) in dict {
202
+ if let nestedValue = normalize(nested) { out[key] = nestedValue }
203
+ }
204
+ return out
205
+ case let array as [Any?]:
206
+ return array.compactMap { normalize($0) }
207
+ default:
208
+ return value
209
+ }
210
+ }
211
+
212
+ private static func stringKeyedDictionary(_ value: Any?) -> [String: Any] {
213
+ if let dict = value as? [String: Any] { return dict }
214
+ if let ns = value as? NSDictionary {
215
+ var out = [String: Any]()
216
+ for (key, val) in ns where key is String {
217
+ out[key as! String] = val
218
+ }
219
+ return out
220
+ }
221
+ return [:]
222
+ }
223
+
224
+ private static func stringKeyedOptionalDictionary(_ value: Any?) -> [String: Any?] {
225
+ if let ns = value as? NSDictionary {
226
+ var out = [String: Any?]()
227
+ for (key, val) in ns where key is String {
228
+ out[key as! String] = val
229
+ }
230
+ return out
231
+ }
232
+ if let dict = value as? [String: Any] {
233
+ var out = [String: Any?]()
234
+ for (key, val) in dict { out[key] = val }
235
+ return out
236
+ }
237
+ return [:]
238
+ }
239
+
240
+ private static func doubleValue(_ value: Any?) -> Double {
241
+ if let number = value as? NSNumber { return number.doubleValue }
242
+ if let double = value as? Double { return double }
243
+ if let float = value as? Float { return Double(float) }
244
+ if let int = value as? Int { return Double(int) }
245
+ return 0
246
+ }
247
+ }
@@ -1,11 +1,17 @@
1
1
  import Accelerate
2
2
  import CoreGraphics
3
3
  import Photos
4
- import React
5
4
  import Foundation
6
5
  import MobileCoreServices
7
6
 
8
7
 
8
+ // Local stand-in for the single `RCTResizeMode` case this file relies on, so the
9
+ // module no longer needs to `import React` (see HybridCompressor for the reason).
10
+ private enum CompressorResizeMode {
11
+ case contain
12
+ }
13
+
14
+
9
15
  class ImageCompressor {
10
16
  static func findTargetSize(_ image: UIImage, maxWidth: Int, maxHeight: Int) -> CGSize {
11
17
  let width = image.size.width
@@ -392,7 +398,7 @@ class ImageCompressor {
392
398
  let imageURL = URL(string: imagePath.replacingOccurrences(of: " ", with: "%20"))
393
399
  var size = CGSize.zero
394
400
  var scale: CGFloat = 1
395
- var resizeMode = RCTResizeMode.contain
401
+ var resizeMode = CompressorResizeMode.contain
396
402
  let assetID = imagePath.replacingOccurrences(of: "ph://", with: "")
397
403
  let results = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil)
398
404
 
@@ -6,49 +6,71 @@
6
6
  //
7
7
 
8
8
  import Foundation
9
+ import UIKit
9
10
 
11
+ /// Routes native progress emissions to the per-call JS callbacks that
12
+ /// `HybridCompressor` registers. This replaces the old `RCTEventEmitter` bridge:
13
+ /// Nitro delivers progress through callback parameters, so the domain layer's
14
+ /// `emit*` calls (unchanged) are dispatched to the callback registered under the
15
+ /// same `uuid` that the JS layer threads through the options map.
10
16
  class EventEmitterHandler {
11
- static var sharedCompressorObject: Any!
12
- static var hasListener: Bool=false
13
-
14
- static func initCompressorInstance(_ object: Any) {
15
- sharedCompressorObject = object
17
+ private static let lock = NSLock()
18
+ private static var videoCompressProgressCallbacks = [String: (Double) -> Void]()
19
+ private static var downloadProgressCallbacks = [String: (Double) -> Void]()
20
+ private static var uploadProgressCallbacks = [String: (Double, Double) -> Void]()
21
+ private static var backgroundTaskExpiredCallback: (() -> Void)?
22
+
23
+ // MARK: - Registration (called by HybridCompressor)
24
+
25
+ static func registerVideoCompressProgress(uuid: String, _ callback: ((Double) -> Void)?) {
26
+ guard let callback = callback else { return }
27
+ lock.lock(); defer { lock.unlock() }
28
+ videoCompressProgressCallbacks[uuid] = callback
29
+ }
30
+
31
+ static func registerDownloadProgress(uuid: String, _ callback: ((Double) -> Void)?) {
32
+ guard let callback = callback else { return }
33
+ lock.lock(); defer { lock.unlock() }
34
+ downloadProgressCallbacks[uuid] = callback
16
35
  }
17
-
18
-
19
- static func stopObserving() -> Void {
20
- hasListener = false
36
+
37
+ static func registerUploadProgress(uuid: String, _ callback: ((Double, Double) -> Void)?) {
38
+ guard let callback = callback else { return }
39
+ lock.lock(); defer { lock.unlock() }
40
+ uploadProgressCallbacks[uuid] = callback
41
+ }
42
+
43
+ static func unregister(uuid: String) {
44
+ lock.lock(); defer { lock.unlock() }
45
+ videoCompressProgressCallbacks[uuid] = nil
46
+ downloadProgressCallbacks[uuid] = nil
47
+ uploadProgressCallbacks[uuid] = nil
21
48
  }
22
49
 
23
- static func startObserving() -> Void {
24
- hasListener = true
50
+ static func setBackgroundTaskExpiredCallback(_ callback: (() -> Void)?) {
51
+ lock.lock(); defer { lock.unlock() }
52
+ backgroundTaskExpiredCallback = callback
25
53
  }
26
-
54
+
55
+ // MARK: - Emission (called by the domain layer — method names preserved)
56
+
27
57
  static func emitDownloadProgress(_ progress: NSNumber, uuid: String) {
28
- var params = [String: Any]()
29
- var data = [String: Any]()
30
- params["uuid"] = uuid
31
- data["progress"] = progress
32
- params["data"] = data
33
-
34
- (sharedCompressorObject as AnyObject).sendEvent(withName: "downloadProgress", body: params)
35
- }
36
-
58
+ lock.lock(); let callback = downloadProgressCallbacks[uuid]; lock.unlock()
59
+ callback?(progress.doubleValue)
60
+ }
61
+
37
62
  static func emitVideoCompressProgress(_ progress: Float, uuid: String) {
38
- if(self.hasListener){
39
- (sharedCompressorObject as AnyObject).sendEvent(withName: "videoCompressProgress", body: ["uuid": uuid, "data": ["progress": progress]])
40
- }
63
+ lock.lock(); let callback = videoCompressProgressCallbacks[uuid]; lock.unlock()
64
+ callback?(Double(progress))
41
65
  }
42
-
66
+
43
67
  static func emituploadProgress(_ uuid: String, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
44
- if(self.hasListener){
45
- (sharedCompressorObject as AnyObject).sendEvent(withName: "uploadProgress", body: ["uuid": uuid, "data": ["written": totalBytesSent, "total": totalBytesExpectedToSend]])
46
- }
47
- }
48
-
49
- static func emitBackgroundTaskExpired(_ backgroundTaskId:UIBackgroundTaskIdentifier) {
50
- if(self.hasListener){
51
- (sharedCompressorObject as AnyObject).sendEvent(withName: "backgroundTaskExpired", body: ["backgroundTaskId": backgroundTaskId])
52
- }
68
+ lock.lock(); let callback = uploadProgressCallbacks[uuid]; lock.unlock()
69
+ callback?(Double(totalBytesSent), Double(totalBytesExpectedToSend))
70
+ }
71
+
72
+ static func emitBackgroundTaskExpired(_ backgroundTaskId: UIBackgroundTaskIdentifier) {
73
+ lock.lock(); let callback = backgroundTaskExpiredCallback; lock.unlock()
74
+ callback?()
53
75
  }
54
76
  }
@@ -13,7 +13,9 @@ public enum NextLevelSessionExporterError: Error, CustomStringConvertible {
13
13
  case readingFailure
14
14
  case writingFailure
15
15
  case cancelled
16
-
16
+ case unsupportedVideoOutputConfiguration
17
+ case missingVideoTrackInOutput
18
+
17
19
  public var description: String {
18
20
  get {
19
21
  switch self {
@@ -25,6 +27,10 @@ public enum NextLevelSessionExporterError: Error, CustomStringConvertible {
25
27
  return "Writing failure"
26
28
  case .cancelled:
27
29
  return "Cancelled"
30
+ case .unsupportedVideoOutputConfiguration:
31
+ return "The writer rejected the video output configuration"
32
+ case .missingVideoTrackInOutput:
33
+ return "Export finished without a video track in the output"
28
34
  }
29
35
  }
30
36
  }
@@ -249,7 +255,16 @@ extension NextLevelSessionExporter {
249
255
  }
250
256
  }
251
257
 
252
- self.setupVideoOutput(withAsset: asset)
258
+ // Fail loudly when the writer rejects the video output configuration.
259
+ // Continuing here used to export only the audio track and still resolve
260
+ // as a success (issue #400).
261
+ guard self.setupVideoOutput(withAsset: asset) else {
262
+ DispatchQueue.main.async {
263
+ self._completionHandler?(.failure(NextLevelSessionExporterError.unsupportedVideoOutputConfiguration))
264
+ self._completionHandler = nil
265
+ }
266
+ return
267
+ }
253
268
  if !self.stripAudio {
254
269
  self.setupAudioOutput(withAsset: asset)
255
270
  self.setupAudioInput()
@@ -316,11 +331,14 @@ extension NextLevelSessionExporter {
316
331
 
317
332
  extension NextLevelSessionExporter {
318
333
 
319
- private func setupVideoOutput(withAsset asset: AVAsset) {
334
+ /// Returns `false` when the asset has video tracks but a video writer input
335
+ /// could not be created — exporting would silently produce an audio-only
336
+ /// file (see numandev1/react-native-compressor#400).
337
+ private func setupVideoOutput(withAsset asset: AVAsset) -> Bool {
320
338
  let videoTracks = asset.tracks(withMediaType: AVMediaType.video)
321
-
339
+
322
340
  guard videoTracks.count > 0 else {
323
- return
341
+ return true
324
342
  }
325
343
 
326
344
  self._videoOutput = AVAssetReaderVideoCompositionOutput(videoTracks: videoTracks, videoSettings: self.videoInputConfiguration)
@@ -344,8 +362,8 @@ extension NextLevelSessionExporter {
344
362
  self._videoInput = AVAssetWriterInput(mediaType: AVMediaType.video, outputSettings: self.videoOutputConfiguration)
345
363
  self._videoInput?.expectsMediaDataInRealTime = self.expectsMediaDataInRealTime
346
364
  } else {
347
- print("Unsupported output configuration")
348
- return
365
+ print("NextLevelSessionExporter, unsupported video output configuration, failing export instead of writing an audio-only file")
366
+ return false
349
367
  }
350
368
 
351
369
  if let writer = self._writer,
@@ -367,8 +385,9 @@ extension NextLevelSessionExporter {
367
385
 
368
386
  self._pixelBufferAdaptor = AVAssetWriterInputPixelBufferAdaptor(assetWriterInput: videoInput, sourcePixelBufferAttributes: pixelBufferAttrib)
369
387
  }
388
+ return true
370
389
  }
371
-
390
+
372
391
  private func setupAudioOutput(withAsset asset: AVAsset) {
373
392
  let audioTracks = asset.tracks(withMediaType: AVMediaType.audio)
374
393
  var audioTracksToUse: [AVAssetTrack] = []
@@ -632,6 +651,22 @@ extension NextLevelSessionExporter {
632
651
  break
633
652
  }
634
653
 
654
+ // Guard against silently returning an audio-only file: when the source
655
+ // has a video track and a video output was configured, the exported file
656
+ // must contain a video track as well. A configuration the encoder rejects
657
+ // at write time can still end with `.completed` while the video track is
658
+ // dropped (issue #400) — surface that as an error instead of a success.
659
+ if self.videoOutputConfiguration != nil,
660
+ let asset = self.asset,
661
+ let outputURL = self.outputURL,
662
+ asset.tracks(withMediaType: AVMediaType.video).count > 0,
663
+ AVAsset(url: outputURL).tracks(withMediaType: AVMediaType.video).count == 0 {
664
+ try? FileManager.default.removeItem(at: outputURL)
665
+ self._completionHandler?(.failure(NextLevelSessionExporterError.missingVideoTrackInOutput))
666
+ self._completionHandler = nil
667
+ return
668
+ }
669
+
635
670
  self._completionHandler?(.success(self.status))
636
671
  self._completionHandler = nil
637
672
  }