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.compressor
2
2
 
3
3
  import android.content.Context
4
4
  import android.media.MediaCodec
5
+ import android.media.MediaCodecInfo
6
+ import android.media.MediaCodecList
5
7
  import android.media.MediaExtractor
6
8
  import android.media.MediaFormat
7
9
  import android.media.MediaMetadataRetriever
@@ -9,6 +11,7 @@ import android.net.Uri
9
11
  import android.os.Build
10
12
  import android.util.Log
11
13
  import com.reactnativecompressor.Video.VideoCompressor.CompressionProgressListener
14
+ import com.reactnativecompressor.Video.VideoCompressor.utils.CompressorUtils.ensureDecodableVideoFormat
12
15
  import com.reactnativecompressor.Video.VideoCompressor.utils.CompressorUtils.findTrack
13
16
  import com.reactnativecompressor.Video.VideoCompressor.utils.CompressorUtils.hasQTI
14
17
  import com.reactnativecompressor.Video.VideoCompressor.utils.CompressorUtils.prepareVideoHeight
@@ -16,6 +19,7 @@ import com.reactnativecompressor.Video.VideoCompressor.utils.CompressorUtils.pre
16
19
  import com.reactnativecompressor.Video.VideoCompressor.utils.CompressorUtils.printException
17
20
  import com.reactnativecompressor.Video.VideoCompressor.utils.CompressorUtils.setOutputFileParameters
18
21
  import com.reactnativecompressor.Video.VideoCompressor.utils.CompressorUtils.setUpMP4Movie
22
+ import com.reactnativecompressor.Video.VideoCompressor.utils.LocationExtractor
19
23
  import com.reactnativecompressor.Video.VideoCompressor.utils.StreamableVideo
20
24
  import com.reactnativecompressor.Video.VideoCompressor.video.InputSurface
21
25
  import com.reactnativecompressor.Video.VideoCompressor.video.MP4Builder
@@ -97,6 +101,29 @@ object Compressor {
97
101
  val rotationData = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
98
102
  val bitrateData = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
99
103
  val durationData = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
104
+ // ISO 6709 string (e.g. "+37.4220-122.0840/"). Forwarded into the
105
+ // output udta/©xyz box so GPS metadata survives the rewrite.
106
+ //
107
+ // Some Samsung firmwares (S10 / Android 12) place "©xyz" in the
108
+ // per-track udta, or use a 'loci' box, or iTunes-style meta/keys+ilst.
109
+ // MediaMetadataRetriever only reads moov/udta/©xyz — returning null
110
+ // (or empty) and dropping GPS. Fall back to a raw MP4 walker that
111
+ // scans the whole file for every known location encoding.
112
+ val retrievedLocation =
113
+ mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
114
+ val locationData = if (!retrievedLocation.isNullOrEmpty()) {
115
+ retrievedLocation
116
+ } else {
117
+ LocationExtractor.extract(context, srcUri)
118
+ }
119
+ // Never log the resolved ISO 6709 string — it is the user's exact GPS
120
+ // coordinates. Log only presence and which mechanism resolved it.
121
+ val locationSource = when {
122
+ !retrievedLocation.isNullOrEmpty() -> "retriever"
123
+ !locationData.isNullOrEmpty() -> "extractor"
124
+ else -> "none"
125
+ }
126
+ Log.i("Compressor", "source location resolved: hasLocation=${!locationData.isNullOrEmpty()} source=$locationSource")
100
127
 
101
128
  // Check if any metadata is missing
102
129
  if (rotationData.isNullOrEmpty() || bitrateData.isNullOrEmpty() || durationData.isNullOrEmpty()) {
@@ -142,7 +169,8 @@ object Compressor {
142
169
  extractor,
143
170
  listener,
144
171
  duration,
145
- rotation
172
+ rotation,
173
+ locationData,
146
174
  )
147
175
  }
148
176
 
@@ -160,31 +188,50 @@ object Compressor {
160
188
  extractor: MediaExtractor,
161
189
  compressionProgressListener: CompressionProgressListener,
162
190
  duration: Long,
163
- rotation: Int
191
+ rotation: Int,
192
+ location: String?,
164
193
  ): Result {
165
194
  // Check if newWidth and newHeight are valid
166
195
  if (newWidth != 0 && newHeight != 0) {
167
196
  // Create a cache file for the compressed video
168
197
  val cacheFile = File(destination)
169
198
 
199
+ // Hoisted so the outer catch can close the muxer even though the val
200
+ // below is scoped to the try. Stays null until createMovie() succeeds.
201
+ var muxer: MP4Builder? = null
202
+
170
203
  try {
171
204
  // MediaCodec accesses encoder and decoder components and processes the new video
172
205
  // input to generate a compressed/smaller size video
173
206
  val bufferInfo = MediaCodec.BufferInfo()
174
207
 
175
- // Setup mp4 movie
176
- val movie = setUpMP4Movie(rotation, cacheFile)
177
-
178
- // MediaMuxer outputs MP4 in this app
179
- val mediaMuxer = MP4Builder().createMovie(movie)
180
-
181
- // Start with the video track
208
+ // Resolve the source video track and its format BEFORE allocating the
209
+ // muxer, encoder or EGL surfaces. Dolby Vision profile 5 has no HEVC
210
+ // base layer and cannot be transcoded; rejecting it here — instead of
211
+ // inside prepareDecoder, after the muxer file stream, encoder and EGL
212
+ // surfaces are already live — avoids leaking those resources on bail-out.
182
213
  val videoIndex = findTrack(extractor, isVideo = true)
183
214
 
184
215
  extractor.selectTrack(videoIndex)
185
216
  extractor.seekTo(0, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
186
217
  val inputFormat = extractor.getTrackFormat(videoIndex)
187
218
 
219
+ if (isUnsupportedDolbyVision(inputFormat)) {
220
+ runCatching { extractor.release() }
221
+ return Result(
222
+ id,
223
+ success = false,
224
+ failureMessage = "Dolby Vision profile 5 has no HEVC base layer; cannot transcode"
225
+ )
226
+ }
227
+
228
+ // Setup mp4 movie
229
+ val movie = setUpMP4Movie(rotation, cacheFile, location)
230
+
231
+ // MediaMuxer outputs MP4 in this app
232
+ val mediaMuxer = MP4Builder().createMovie(movie)
233
+ muxer = mediaMuxer
234
+
188
235
  val outputFormat: MediaFormat =
189
236
  MediaFormat.createVideoFormat(MIME_TYPE, newWidth, newHeight)
190
237
 
@@ -196,16 +243,31 @@ object Compressor {
196
243
  outputFrameRate,
197
244
  )
198
245
 
199
- val decoder: MediaCodec
200
-
201
246
  // Check if QTI hardware acceleration is available
202
247
  val hasQTI = hasQTI()
203
248
 
204
- // Prepare the video encoder
205
- val encoder = prepareEncoder(outputFormat, hasQTI)
249
+ // Prepare the video encoder. If the encoder rejects the throughput-tuned
250
+ // format at configure() time, prepareEncoder reconfigures using this
251
+ // baseline format (same params, no VBR/priority/operating-rate keys).
252
+ val encoder = prepareEncoder(outputFormat, hasQTI) {
253
+ MediaFormat.createVideoFormat(MIME_TYPE, newWidth, newHeight).also {
254
+ setOutputFileParameters(
255
+ inputFormat,
256
+ it,
257
+ newBitrate,
258
+ outputFrameRate,
259
+ applyThroughputTuning = false,
260
+ )
261
+ }
262
+ }
206
263
 
207
- val inputSurface: InputSurface
208
- val outputSurface: OutputSurface
264
+ // Track pipeline handles as they come up so a failure mid-setup
265
+ // (EGL/GL init, decoder configure, encoder.start) releases whatever was
266
+ // already created instead of leaking it. The encoder above is always
267
+ // non-null by this point.
268
+ var decoderRef: MediaCodec? = null
269
+ var inputSurfaceRef: InputSurface? = null
270
+ var outputSurfaceRef: OutputSurface? = null
209
271
 
210
272
  try {
211
273
  var inputDone = false
@@ -213,32 +275,58 @@ object Compressor {
213
275
 
214
276
  var videoTrackIndex = -5
215
277
 
216
- inputSurface = InputSurface(encoder.createInputSurface())
278
+ // Frame dropping: when source fps is higher than target output fps,
279
+ // skip decoded frames whose PTS falls before the next target slot.
280
+ // Saves GL render + encoder work proportional to the drop ratio
281
+ // (e.g. 60fps → 30fps cuts pipeline work roughly in half).
282
+ //
283
+ // Only enable dropping when the source frame rate is reliably
284
+ // higher than the target. If the source advertises 30 fps and the
285
+ // target is 30 fps, even tiny PTS jitter can push a frame just
286
+ // before its slot, get it dropped, and turn 30 fps output into
287
+ // 20 fps — the choppy playback users reported.
288
+ val sourceFrameRate: Int = if (inputFormat.containsKey(MediaFormat.KEY_FRAME_RATE)) {
289
+ inputFormat.getInteger(MediaFormat.KEY_FRAME_RATE)
290
+ } else 0
291
+ val shouldDropFrames = outputFrameRate > 0 &&
292
+ sourceFrameRate > 0 &&
293
+ outputFrameRate < sourceFrameRate
294
+ val targetFrameIntervalUs: Long =
295
+ if (shouldDropFrames) 1_000_000L / outputFrameRate else 0L
296
+ var nextTargetPtsUs: Long = 0L
297
+
298
+ val inputSurface = InputSurface(encoder.createInputSurface())
299
+ inputSurfaceRef = inputSurface
217
300
  inputSurface.makeCurrent()
218
301
  // Move to executing state
219
302
  encoder.start()
220
303
 
221
- outputSurface = OutputSurface()
304
+ val outputSurface = OutputSurface()
305
+ outputSurfaceRef = outputSurface
222
306
 
223
- decoder = prepareDecoder(inputFormat, outputSurface)
307
+ val decoder = prepareDecoder(inputFormat, outputSurface)
308
+ decoderRef = decoder
224
309
 
225
310
  // Move to executing state
226
311
  decoder.start()
227
312
 
228
313
  while (!outputDone) {
229
314
  if (!inputDone) {
230
-
231
- val index = extractor.sampleTrackIndex
232
-
233
- if (index == videoIndex) {
234
- val inputBufferIndex =
235
- decoder.dequeueInputBuffer(MEDIACODEC_TIMEOUT_DEFAULT)
236
- if (inputBufferIndex >= 0) {
315
+ // Feed the decoder until it has no free input slots or the
316
+ // extractor is empty. HW codecs typically have 4-8 input
317
+ // slots; queuing only one sample per outer iteration starves
318
+ // the pipeline and forces serial decode-render-encode.
319
+ feedLoop@ while (!inputDone) {
320
+ val index = extractor.sampleTrackIndex
321
+
322
+ if (index == videoIndex) {
323
+ val inputBufferIndex =
324
+ decoder.dequeueInputBuffer(0L)
325
+ if (inputBufferIndex < 0) break@feedLoop
237
326
  val inputBuffer = decoder.getInputBuffer(inputBufferIndex)
238
327
  val chunkSize = extractor.readSampleData(inputBuffer!!, 0)
239
328
  when {
240
329
  chunkSize < 0 -> {
241
-
242
330
  decoder.queueInputBuffer(
243
331
  inputBufferIndex,
244
332
  0,
@@ -249,7 +337,6 @@ object Compressor {
249
337
  inputDone = true
250
338
  }
251
339
  else -> {
252
-
253
340
  decoder.queueInputBuffer(
254
341
  inputBufferIndex,
255
342
  0,
@@ -258,15 +345,12 @@ object Compressor {
258
345
  0
259
346
  )
260
347
  extractor.advance()
261
-
262
348
  }
263
349
  }
264
- }
265
-
266
- } else if (index == -1) { //end of file
267
- val inputBufferIndex =
268
- decoder.dequeueInputBuffer(MEDIACODEC_TIMEOUT_DEFAULT)
269
- if (inputBufferIndex >= 0) {
350
+ } else if (index == -1) { //end of file
351
+ val inputBufferIndex =
352
+ decoder.dequeueInputBuffer(0L)
353
+ if (inputBufferIndex < 0) break@feedLoop
270
354
  decoder.queueInputBuffer(
271
355
  inputBufferIndex,
272
356
  0,
@@ -275,6 +359,9 @@ object Compressor {
275
359
  MediaCodec.BUFFER_FLAG_END_OF_STREAM
276
360
  )
277
361
  inputDone = true
362
+ } else {
363
+ // Different track type at head of extractor (audio etc.).
364
+ break@feedLoop
278
365
  }
279
366
  }
280
367
  }
@@ -353,7 +440,27 @@ object Compressor {
353
440
  }
354
441
  decoderStatus < 0 -> throw RuntimeException("unexpected result from decoder.dequeueOutputBuffer: $decoderStatus")
355
442
  else -> {
356
- val doRender = bufferInfo.size != 0
443
+ val isEos = (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0
444
+ var doRender = bufferInfo.size != 0 && !isEos
445
+
446
+ // Drop frames whose PTS falls before the next target slot.
447
+ // Anchor the next slot to the ideal grid (previous slot +
448
+ // interval) instead of to the actual PTS — anchoring to PTS
449
+ // lets source-side jitter compound into extra drops, which
450
+ // collapses the output frame rate well below the target.
451
+ if (doRender && targetFrameIntervalUs > 0L) {
452
+ if (bufferInfo.presentationTimeUs < nextTargetPtsUs) {
453
+ doRender = false
454
+ } else {
455
+ nextTargetPtsUs += targetFrameIntervalUs
456
+ // Snap forward when the source skips past a slot
457
+ // (gap, seek, very low source fps) so the gate doesn't
458
+ // burst-emit every following frame.
459
+ if (bufferInfo.presentationTimeUs >= nextTargetPtsUs) {
460
+ nextTargetPtsUs = bufferInfo.presentationTimeUs + targetFrameIntervalUs
461
+ }
462
+ }
463
+ }
357
464
 
358
465
  decoder.releaseOutputBuffer(decoderStatus, doRender)
359
466
  if (doRender) {
@@ -392,16 +499,32 @@ object Compressor {
392
499
 
393
500
  } catch (exception: Throwable) {
394
501
  printException(exception)
502
+ // Release whatever was initialized before the failure. Setup errors
503
+ // (EGL/GL init, decoder configure, encoder.start) and in-loop throws
504
+ // land here; without this the encoder + EGL surfaces would leak and
505
+ // break the next compression. dispose() tolerates the null handles
506
+ // that occur when the failure happens mid-setup.
507
+ dispose(
508
+ videoIndex,
509
+ decoderRef,
510
+ encoder,
511
+ inputSurfaceRef,
512
+ outputSurfaceRef,
513
+ extractor
514
+ )
515
+ // finishMovie() never runs on this path, so close the MP4Builder
516
+ // streams explicitly or the output file handle leaks.
517
+ mediaMuxer.close()
395
518
  return Result(id, success = false, failureMessage = exception.message)
396
519
  }
397
520
 
398
521
  // Release resources
399
522
  dispose(
400
523
  videoIndex,
401
- decoder,
524
+ decoderRef,
402
525
  encoder,
403
- inputSurface,
404
- outputSurface,
526
+ inputSurfaceRef,
527
+ outputSurfaceRef,
405
528
  extractor
406
529
  )
407
530
 
@@ -418,18 +541,27 @@ object Compressor {
418
541
  mediaMuxer.finishMovie()
419
542
  } catch (e: Throwable) {
420
543
  printException(e)
544
+ // finishMovie() may throw before it closes its own streams; close
545
+ // them here so a finalize failure doesn't leak the file handle.
546
+ mediaMuxer.close()
421
547
  return Result(id, success = false, failureMessage = e.message ?: "Failed to finalize compressed video")
422
548
  }
423
549
 
424
550
  } catch (exception: Throwable) {
425
551
  printException(exception)
552
+ // Covers throws after the inner pipeline closed (e.g. processAudio,
553
+ // extractor.release) where the MP4Builder is still open. close() is
554
+ // idempotent, so calling it after a successful finishMovie() is a no-op.
555
+ muxer?.close()
426
556
  return Result(id, success = false, failureMessage = exception.message)
427
557
  }
428
558
 
429
559
  var resultFile = cacheFile
430
560
 
431
561
  try {
432
- // Keep default outputs browser-compatible by moving the MP4 metadata before media data.
562
+ // Keep default outputs browser/progressive-playback compatible by moving the
563
+ // MP4 moov atom in front of the media data. This runs for every output; when
564
+ // no explicit streamableFile is requested, the rewritten copy replaces cacheFile.
433
565
  val targetFile = streamableFile?.let { File(it) } ?: getStreamableOutputFile(cacheFile)
434
566
  val outputFile = if (targetFile.absolutePath == cacheFile.absolutePath) {
435
567
  getStreamableOutputFile(cacheFile)
@@ -547,25 +679,109 @@ object Compressor {
547
679
  }
548
680
 
549
681
  // Function to prepare the video encoder
550
- private fun prepareEncoder(outputFormat: MediaFormat, hasQTI: Boolean): MediaCodec {
551
-
552
- // This seems to cause an issue with certain phones
553
- // val encoderName = MediaCodecList(REGULAR_CODECS).findEncoderForFormat(outputFormat)
554
- // val encoder: MediaCodec = MediaCodec.createByCodecName(encoderName)
555
- // Log.i("encoderName", encoder.name)
556
- // c2.qti.avc.encoder results in a corrupted .mp4 video that does not play in
557
- // Mac and iphones
558
- val encoder = if (hasQTI) {
682
+ private fun prepareEncoder(
683
+ outputFormat: MediaFormat,
684
+ hasQTI: Boolean,
685
+ baselineFormatProvider: () -> MediaFormat,
686
+ ): MediaCodec {
687
+ // Prefer hardware AVC encoder while skipping known-broken QTI codec that
688
+ // produces files unplayable on Mac/iOS (c2.qti.avc.encoder).
689
+ val encoder = pickAvcEncoder(outputFormat, hasQTI)
690
+ try {
691
+ encoder.configure(
692
+ outputFormat, null, null,
693
+ MediaCodec.CONFIGURE_FLAG_ENCODE
694
+ )
695
+ Log.i("Compressor", "encoder selected: ${encoder.name}")
696
+ return encoder
697
+ } catch (e: Exception) {
698
+ // Some encoders reject the throughput-tuning keys (VBR bitrate mode,
699
+ // priority, operating rate) at configure() time. A codec that throws
700
+ // from configure() is unusable, so release it and retry on a fresh
701
+ // codec with a baseline format (default rate control) rather than
702
+ // failing the whole compression.
703
+ Log.w(
704
+ "Compressor",
705
+ "encoder.configure rejected tuned format; retrying with default settings",
706
+ e
707
+ )
708
+ runCatching { encoder.release() }
709
+ }
710
+
711
+ val baseline = baselineFormatProvider()
712
+ val fallback = pickAvcEncoder(baseline, hasQTI)
713
+ try {
714
+ fallback.configure(
715
+ baseline, null, null,
716
+ MediaCodec.CONFIGURE_FLAG_ENCODE
717
+ )
718
+ } catch (e: Exception) {
719
+ // Even the baseline format was rejected; release the codec so it
720
+ // doesn't leak, then let start()'s outer catch report the failure.
721
+ runCatching { fallback.release() }
722
+ throw e
723
+ }
724
+ Log.i("Compressor", "encoder selected (fallback, default rate control): ${fallback.name}")
725
+ return fallback
726
+ }
727
+
728
+ private fun pickAvcEncoder(outputFormat: MediaFormat, hasQTI: Boolean): MediaCodec {
729
+ // ALL_CODECS surfaces vendor codecs that REGULAR_CODECS hides (e.g. some
730
+ // Exynos / MTK HW encoders). We still filter blacklisted / SW codecs below.
731
+ val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
732
+ val candidates = codecList.codecInfos.filter { info ->
733
+ info.isEncoder && info.supportedTypes.any { it.equals(MIME_TYPE, ignoreCase = true) }
734
+ }
735
+
736
+ fun isBlacklisted(name: String): Boolean {
737
+ val lower = name.lowercase()
738
+ return lower.contains("c2.qti.avc.encoder") || lower.contains("omx.qcom.video.encoder.avc.secure")
739
+ }
740
+
741
+ fun isSoftware(info: MediaCodecInfo): Boolean {
742
+ val name = info.name.lowercase()
743
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
744
+ if (info.isSoftwareOnly) return true
745
+ }
746
+ return name.startsWith("omx.google.") ||
747
+ name.startsWith("c2.android.") ||
748
+ name.contains(".sw.")
749
+ }
750
+
751
+ val supportsFormat = candidates.filter { info ->
752
+ runCatching {
753
+ info.getCapabilitiesForType(MIME_TYPE).isFormatSupported(outputFormat)
754
+ }.getOrDefault(false) && !isBlacklisted(info.name)
755
+ }
756
+
757
+ val hardwareFirst = supportsFormat.firstOrNull { !isSoftware(it) }
758
+ val chosen = hardwareFirst ?: supportsFormat.firstOrNull()
759
+
760
+ if (chosen != null) {
761
+ return MediaCodec.createByCodecName(chosen.name)
762
+ }
763
+
764
+ // Fallback: keep historical QTI-safe path when format probing fails.
765
+ return if (hasQTI) {
559
766
  MediaCodec.createByCodecName("c2.android.avc.encoder")
560
767
  } else {
561
768
  MediaCodec.createEncoderByType(MIME_TYPE)
562
769
  }
563
- encoder.configure(
564
- outputFormat, null, null,
565
- MediaCodec.CONFIGURE_FLAG_ENCODE
566
- )
770
+ }
567
771
 
568
- return encoder
772
+ // Dolby Vision profile 5 (0x20) carries no HEVC base layer, so no standard
773
+ // Android decoder can render it. Detect it up front so start() can reject the
774
+ // input before allocating the muxer/encoder/EGL surfaces. Profiles 8.x do carry
775
+ // an HEVC base layer and are remapped to HEVC in prepareDecoder.
776
+ private fun isUnsupportedDolbyVision(inputFormat: MediaFormat): Boolean {
777
+ val mime = inputFormat.getString(MediaFormat.KEY_MIME) ?: return false
778
+ if (!mime.equals("video/dolby-vision", ignoreCase = true)) return false
779
+ val profile = if (inputFormat.containsKey(MediaFormat.KEY_PROFILE)) {
780
+ inputFormat.getInteger(MediaFormat.KEY_PROFILE)
781
+ } else {
782
+ -1
783
+ }
784
+ return profile == 0x20
569
785
  }
570
786
 
571
787
  // Function to prepare the video decoder
@@ -573,42 +789,61 @@ object Compressor {
573
789
  inputFormat: MediaFormat,
574
790
  outputSurface: OutputSurface,
575
791
  ): MediaCodec {
576
- // This seems to cause an issue with certain phones
577
- // val decoderName =
578
- // MediaCodecList(REGULAR_CODECS).findDecoderForFormat(inputFormat)
579
- // val decoder = MediaCodec.createByCodecName(decoderName)
580
- // Log.i("decoderName", decoder.name)
581
792
 
582
- // val decoder = if (hasQTI) {
583
- // MediaCodec.createByCodecName("c2.android.avc.decoder")
584
- //} else {
793
+ // Some inputs (e.g. iPhone .MOV files) report a "video/dolby-vision" MIME
794
+ // type that many devices cannot decode. Remap to a decodable base-layer
795
+ // codec, or fail with a clear error, before creating the decoder (#398).
796
+ ensureDecodableVideoFormat(inputFormat)
797
+
798
+ // Clear Dolby Vision specific profile and level to prevent configuration failures
799
+ // when the MIME type has been remapped to AVC/HEVC.
800
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
801
+ inputFormat.removeKey(MediaFormat.KEY_PROFILE)
802
+ inputFormat.removeKey(MediaFormat.KEY_LEVEL)
803
+ }
585
804
 
586
805
  val decoder = MediaCodec.createDecoderByType(inputFormat.getString(MediaFormat.KEY_MIME)!!)
587
- //}
588
806
 
589
807
  decoder.configure(inputFormat, outputSurface.getSurface(), null, 0)
590
808
 
591
809
  return decoder
592
810
  }
593
811
 
594
- // Function to release resources
812
+ // Function to release resources.
813
+ // Every call is wrapped in runCatching so a failure in one teardown step
814
+ // does not skip the others (leaking codec handles + GL surfaces). Order:
815
+ // detach extractor → stop+release decoder → stop+release encoder →
816
+ // release input EGL surface → release output surface (joins its
817
+ // HandlerThread). Releasing surfaces last avoids the encoder asking a
818
+ // freed EGL surface for buffers during its own shutdown.
819
+ //
820
+ // decoder / inputSurface / outputSurface are nullable so this also serves the
821
+ // partial-init cleanup path, where a setup failure leaves some handles
822
+ // uncreated. The encoder is always created before teardown is reachable.
595
823
  private fun dispose(
596
824
  videoIndex: Int,
597
- decoder: MediaCodec,
825
+ decoder: MediaCodec?,
598
826
  encoder: MediaCodec,
599
- inputSurface: InputSurface,
600
- outputSurface: OutputSurface,
827
+ inputSurface: InputSurface?,
828
+ outputSurface: OutputSurface?,
601
829
  extractor: MediaExtractor
602
830
  ) {
603
- extractor.unselectTrack(videoIndex)
604
-
605
- decoder.stop()
606
- decoder.release()
607
-
608
- encoder.stop()
609
- encoder.release()
610
-
611
- inputSurface.release()
612
- outputSurface.release()
831
+ runCatching { extractor.unselectTrack(videoIndex) }
832
+ .onFailure { Log.w("Compressor", "extractor.unselectTrack failed", it) }
833
+
834
+ runCatching { decoder?.stop() }
835
+ .onFailure { Log.w("Compressor", "decoder.stop failed", it) }
836
+ runCatching { decoder?.release() }
837
+ .onFailure { Log.w("Compressor", "decoder.release failed", it) }
838
+
839
+ runCatching { encoder.stop() }
840
+ .onFailure { Log.w("Compressor", "encoder.stop failed", it) }
841
+ runCatching { encoder.release() }
842
+ .onFailure { Log.w("Compressor", "encoder.release failed", it) }
843
+
844
+ runCatching { inputSurface?.release() }
845
+ .onFailure { Log.w("Compressor", "inputSurface.release failed", it) }
846
+ runCatching { outputSurface?.release() }
847
+ .onFailure { Log.w("Compressor", "outputSurface.release failed", it) }
613
848
  }
614
849
  }