react-native-compressor 1.18.1 → 1.19.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 (29) hide show
  1. package/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt +44 -23
  2. package/android/src/main/java/com/reactnativecompressor/Utils/createVideoThumbnail.kt +42 -21
  3. package/android/src/main/java/com/reactnativecompressor/Video/AutoVideoCompression.kt +1 -1
  4. package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressionProfile.kt +21 -11
  5. package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt +369 -87
  6. package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/CompressorUtils.kt +24 -5
  7. package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/LocationExtractor.kt +397 -0
  8. package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/LocationBox.kt +47 -0
  9. package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/MP4Builder.kt +39 -7
  10. package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/Mp4Movie.kt +7 -0
  11. package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/OutputSurface.kt +30 -4
  12. package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressorHelper.kt +24 -1
  13. package/ios/Image/ImageCompressor.swift +3 -2
  14. package/ios/Utils/CreateVideoThumbnail.swift +40 -21
  15. package/ios/Utils/Uploader.swift +14 -7
  16. package/ios/Video/VideoMain.swift +27 -11
  17. package/lib/commonjs/index.js +1 -0
  18. package/lib/commonjs/index.js.map +1 -1
  19. package/lib/commonjs/utils/index.js.map +1 -1
  20. package/lib/module/index.js +1 -0
  21. package/lib/module/index.js.map +1 -1
  22. package/lib/module/utils/index.js.map +1 -1
  23. package/lib/typescript/src/index.d.ts +2 -0
  24. package/lib/typescript/src/index.d.ts.map +1 -1
  25. package/lib/typescript/src/utils/index.d.ts +1 -0
  26. package/lib/typescript/src/utils/index.d.ts.map +1 -1
  27. package/package.json +7 -1
  28. package/src/index.tsx +1 -0
  29. package/src/utils/index.tsx +1 -0
@@ -9,6 +9,7 @@ import android.graphics.Paint
9
9
  import android.media.ExifInterface
10
10
  import android.net.Uri
11
11
  import android.util.Base64
12
+ import android.util.Log
12
13
  import com.facebook.react.bridge.ReactApplicationContext
13
14
  import com.reactnativecompressor.Utils.MediaCache
14
15
  import com.reactnativecompressor.Utils.Utils.exifAttributes
@@ -21,6 +22,8 @@ import java.io.IOException
21
22
  import java.net.MalformedURLException
22
23
 
23
24
  object ImageCompressor {
25
+ private const val TAG = "ImageCompressor"
26
+
24
27
  fun getRNFileUrl(filePath: String?): String? {
25
28
  var filePath = filePath
26
29
  val returnAbleFile = File(filePath)
@@ -56,25 +59,38 @@ object ImageCompressor {
56
59
  return BitmapFactory.decodeFile(filePath)
57
60
  }
58
61
 
59
- fun copyExifInfo(imagePath:String, outputUri:String){
60
- try {
61
- // for copy exif info
62
- val sourceExif = ExifInterface(imagePath)
63
- val compressedExif = ExifInterface(outputUri)
64
- for (tag in exifAttributes) {
65
- val compressedValue = compressedExif.getAttribute(tag)
66
- if(compressedValue==null)
67
- {
68
- val sourceValue = sourceExif.getAttribute(tag)
69
- if (sourceValue != null) {
70
- compressedExif.setAttribute(tag, sourceValue)
62
+ /**
63
+ * Strip "file://" / "content://" scheme so legacy ExifInterface can open
64
+ * the underlying JPEG. ExifInterface(String) only accepts raw filesystem
65
+ * paths passing a URI string makes it fail silently inside the
66
+ * try/catch and drops every EXIF tag, including GPS.
67
+ */
68
+ private fun normalizeToFilePath(input: String): String {
69
+ if (input.startsWith("file://") || input.startsWith("content://")) {
70
+ return Uri.parse(input).path ?: input
71
+ }
72
+ return input
73
+ }
74
+
75
+ fun copyExifInfo(imagePath: String, outputUri: String) {
76
+ try {
77
+ val sourcePath = normalizeToFilePath(imagePath)
78
+ val outPath = normalizeToFilePath(outputUri)
79
+ val sourceExif = ExifInterface(sourcePath)
80
+ val compressedExif = ExifInterface(outPath)
81
+ var copied = 0
82
+ for (tag in exifAttributes) {
83
+ val sourceValue = sourceExif.getAttribute(tag) ?: continue
84
+ if (compressedExif.getAttribute(tag) == null) {
85
+ compressedExif.setAttribute(tag, sourceValue)
86
+ copied++
87
+ }
71
88
  }
72
- }
89
+ compressedExif.saveAttributes()
90
+ Log.i(TAG, "copyExifInfo copied $copied tags from $sourcePath -> $outPath")
91
+ } catch (e: Exception) {
92
+ Log.w(TAG, "copyExifInfo failed for $imagePath", e)
73
93
  }
74
- compressedExif.saveAttributes()
75
- } catch (e: Exception) {
76
- e.printStackTrace()
77
- }
78
94
  }
79
95
 
80
96
  fun encodeImage(imageDataByteArrayOutputStream: ByteArrayOutputStream, isBase64: Boolean, outputExtension: String?,imagePath: String?, reactContext: ReactApplicationContext?): String? {
@@ -84,10 +100,14 @@ object ImageCompressor {
84
100
  } else {
85
101
  val outputUri = generateCacheFilePath(outputExtension!!, reactContext!!)
86
102
  try {
87
- val fos = FileOutputStream(outputUri)
88
- imageDataByteArrayOutputStream.writeTo(fos)
103
+ // Close the stream before ExifInterface re-opens the file so
104
+ // the JPEG bytes are fully flushed; otherwise saveAttributes()
105
+ // may truncate the in-flight write.
106
+ FileOutputStream(outputUri).use { fos ->
107
+ imageDataByteArrayOutputStream.writeTo(fos)
108
+ }
89
109
 
90
- copyExifInfo(imagePath!!, outputUri)
110
+ copyExifInfo(imagePath!!, outputUri)
91
111
 
92
112
  return getRNFileUrl(outputUri)
93
113
  } catch (e: Exception) {
@@ -113,16 +133,17 @@ object ImageCompressor {
113
133
 
114
134
  fun compress(image: Bitmap?, output: ImageCompressorOptions.OutputType, quality: Float,disablePngTransparency:Boolean): ByteArrayOutputStream {
115
135
  var stream = ByteArrayOutputStream()
136
+ val normalizedQuality = Math.round(100 * quality.coerceIn(0f, 1f))
116
137
  if (output === ImageCompressorOptions.OutputType.jpg)
117
138
  {
118
- image!!.compress(CompressFormat.JPEG, Math.round(100 * quality), stream)
139
+ image!!.compress(CompressFormat.JPEG, normalizedQuality, stream)
119
140
  }
120
141
  else
121
142
  {
122
143
  var bitmap = image
123
144
  if(disablePngTransparency)
124
145
  {
125
- image!!.compress(CompressFormat.JPEG, Math.round(100 * quality), stream)
146
+ image!!.compress(CompressFormat.JPEG, normalizedQuality, stream)
126
147
  val byteArray: ByteArray = stream.toByteArray()
127
148
  stream=ByteArrayOutputStream()
128
149
  bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size)
@@ -261,7 +282,7 @@ object ImageCompressor {
261
282
  if (bitmap == null || imagePath == null) return bitmap
262
283
 
263
284
  return try {
264
- val exif = ExifInterface(imagePath)
285
+ val exif = ExifInterface(normalizeToFilePath(imagePath))
265
286
  val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
266
287
  val matrix = Matrix()
267
288
 
@@ -62,6 +62,7 @@ class CreateVideoThumbnailClass(private val reactContext: ReactApplicationContex
62
62
  }
63
63
 
64
64
  val headers: Map<String, String> = if (options.hasKey("headers")) options.getMap("headers")!!.toHashMap() as Map<String, String> else HashMap<String, String>()
65
+ val quality = if (options.hasKey("quality")) (options.getDouble("quality") * 100).toInt().coerceIn(0, 100) else DEFAULT_THUMBNAIL_QUALITY
65
66
  val fileName = if (TextUtils.isEmpty(cacheName)) "thumb-" + UUID.randomUUID().toString() else "$cacheName.$format"
66
67
  var fOut: OutputStream? = null
67
68
 
@@ -73,7 +74,7 @@ class CreateVideoThumbnailClass(private val reactContext: ReactApplicationContex
73
74
  fOut = FileOutputStream(file)
74
75
 
75
76
  // 100 means no compression, the lower you go, the stronger the compression
76
- image.compress(Bitmap.CompressFormat.JPEG, 90, fOut)
77
+ image.compress(Bitmap.CompressFormat.JPEG, quality, fOut)
77
78
  fOut.flush()
78
79
  fOut.close()
79
80
 
@@ -96,6 +97,8 @@ class CreateVideoThumbnailClass(private val reactContext: ReactApplicationContex
96
97
  }
97
98
 
98
99
  companion object {
100
+ private const val DEFAULT_THUMBNAIL_QUALITY = 90
101
+
99
102
  // delete previously added files one by one untill requred space is available
100
103
  fun clearCache(cacheDir: String?,promise:Promise, reactContext: ReactApplicationContext) {
101
104
  val cacheDirectory=cacheDir?.takeIf { it.isNotEmpty() } ?:"/thumbnails"
@@ -134,29 +137,47 @@ class CreateVideoThumbnailClass(private val reactContext: ReactApplicationContex
134
137
  }
135
138
 
136
139
  private fun getBitmapAtTime(context: Context?, filePath: String?, time: Int, headers: Map<String, String>): Bitmap {
140
+ check(!filePath.isNullOrEmpty()) { "Video file path cannot be null or empty" }
137
141
  val retriever = MediaMetadataRetriever()
138
- if (URLUtil.isFileUrl(filePath)) {
139
- val decodedPath: String?
140
- decodedPath = try {
141
- URLDecoder.decode(filePath, "UTF-8")
142
- } catch (e: UnsupportedEncodingException) {
143
- filePath
144
- }
145
- retriever.setDataSource(decodedPath!!.replace("file://", ""))
146
- } else if (filePath!!.contains("content://")) {
147
- retriever.setDataSource(context, Uri.parse(filePath))
148
- } else {
149
- check(Build.VERSION.SDK_INT >= 14) { "Remote videos aren't supported on sdk_version < 14" }
150
- retriever.setDataSource(filePath, headers)
151
- }
152
- val image = retriever.getFrameAtTime((time * 1000).toLong(), MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
153
142
  try {
154
- retriever.release()
155
- } catch (e: IOException) {
156
- throw RuntimeException(e)
143
+ if (URLUtil.isFileUrl(filePath)) {
144
+ val decodedPath: String? = try {
145
+ URLDecoder.decode(filePath, "UTF-8")
146
+ } catch (e: UnsupportedEncodingException) {
147
+ filePath
148
+ }
149
+ retriever.setDataSource(decodedPath!!.replace("file://", ""))
150
+ } else if (filePath.contains("content://")) {
151
+ retriever.setDataSource(context, Uri.parse(filePath))
152
+ } else {
153
+ check(Build.VERSION.SDK_INT >= 14) { "Remote videos aren't supported on sdk_version < 14" }
154
+ retriever.setDataSource(filePath, headers)
155
+ }
156
+
157
+ val requestedTimeUs = (time * 1000).toLong()
158
+ val frameAttempts = arrayOf(
159
+ Pair(requestedTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC),
160
+ Pair(requestedTimeUs, MediaMetadataRetriever.OPTION_CLOSEST),
161
+ Pair(1_000_000L, MediaMetadataRetriever.OPTION_CLOSEST_SYNC),
162
+ )
163
+ for ((timeUs, option) in frameAttempts) {
164
+ val image = try {
165
+ retriever.getFrameAtTime(timeUs, option)
166
+ } catch (e: RuntimeException) {
167
+ null
168
+ }
169
+ if (image != null) {
170
+ return image
171
+ }
172
+ }
173
+ error("Unable to extract video frame from file")
174
+ } finally {
175
+ try {
176
+ retriever.release()
177
+ } catch (e: IOException) {
178
+ // Ignore
179
+ }
157
180
  }
158
- checkNotNull(image) { "File doesn't exist or not supported" }
159
- return image
160
181
  }
161
182
  }
162
183
  }
@@ -24,7 +24,7 @@ object AutoVideoCompression {
24
24
  val actualHeight = VideoCompressorHelper.getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
25
25
  val actualWidth = VideoCompressorHelper.getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
26
26
  val bitrate = VideoCompressorHelper.getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_BITRATE)
27
- val frameRate = VideoCompressorHelper.getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)
27
+ val frameRate = VideoCompressorHelper.getSourceFrameRate(metaRetriever)
28
28
  if (actualHeight <= 0 || actualWidth <= 0) {
29
29
  promise.reject(Throwable("Failed to read the input video dimensions"))
30
30
  return
@@ -12,7 +12,12 @@ data class VideoCompressionProfile(
12
12
  )
13
13
 
14
14
  object VideoCompressionProfileFactory {
15
+ // Fallback when the source frame rate cannot be detected.
15
16
  private const val DEFAULT_FRAME_RATE = 30
17
+ // Hard upper bound. 60 fps covers every modern phone capture (24/25/30/
18
+ // 50/60). Capping at 30 — the previous behaviour — silently halved 60
19
+ // fps recordings and made the output look choppy.
20
+ private const val MAX_FRAME_RATE = 60
16
21
 
17
22
  fun createAuto(
18
23
  sourceWidth: Int,
@@ -99,7 +104,7 @@ object VideoCompressionProfileFactory {
99
104
  return DEFAULT_FRAME_RATE
100
105
  }
101
106
 
102
- return sourceFrameRate.coerceIn(1, DEFAULT_FRAME_RATE)
107
+ return sourceFrameRate.coerceIn(1, MAX_FRAME_RATE)
103
108
  }
104
109
 
105
110
  private fun estimateBitrate(
@@ -111,20 +116,25 @@ object VideoCompressionProfileFactory {
111
116
  targetHeight: Int,
112
117
  targetFrameRate: Int,
113
118
  ): Int {
119
+ // WhatsApp-style bitrate envelope. The previous floors/ceilings
120
+ // were ~2-3x larger and produced "compressed" outputs that were
121
+ // still 20-40 MB for short clips. These bands target ~1.5 Mbps at
122
+ // 720p, which matches WhatsApp's typical output size while keeping
123
+ // visual quality acceptable for chat playback.
114
124
  val targetLongSide = max(targetWidth, targetHeight)
115
125
  val floor = when {
116
- targetLongSide >= 1920 -> 4_000_000
117
- targetLongSide >= 1280 -> 2_200_000
118
- targetLongSide >= 960 -> 1_600_000
119
- targetLongSide >= 720 -> 1_200_000
120
- else -> 850_000
126
+ targetLongSide >= 1920 -> 2_000_000
127
+ targetLongSide >= 1280 -> 1_200_000
128
+ targetLongSide >= 960 -> 900_000
129
+ targetLongSide >= 720 -> 700_000
130
+ else -> 500_000
121
131
  }
122
132
  val ceiling = when {
123
- targetLongSide >= 1920 -> 8_000_000
124
- targetLongSide >= 1280 -> 5_000_000
125
- targetLongSide >= 960 -> 3_500_000
126
- targetLongSide >= 720 -> 2_500_000
127
- else -> 1_500_000
133
+ targetLongSide >= 1920 -> 3_500_000
134
+ targetLongSide >= 1280 -> 2_000_000
135
+ targetLongSide >= 960 -> 1_500_000
136
+ targetLongSide >= 720 -> 1_200_000
137
+ else -> 900_000
128
138
  }
129
139
 
130
140
  if (sourceBitrate <= 0) {