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