react-native-compressor 1.18.2 → 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.
@@ -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
@@ -97,6 +100,29 @@ object Compressor {
97
100
  val rotationData = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
98
101
  val bitrateData = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
99
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")
100
126
 
101
127
  // Check if any metadata is missing
102
128
  if (rotationData.isNullOrEmpty() || bitrateData.isNullOrEmpty() || durationData.isNullOrEmpty()) {
@@ -142,7 +168,8 @@ object Compressor {
142
168
  extractor,
143
169
  listener,
144
170
  duration,
145
- rotation
171
+ rotation,
172
+ locationData,
146
173
  )
147
174
  }
148
175
 
@@ -160,31 +187,50 @@ object Compressor {
160
187
  extractor: MediaExtractor,
161
188
  compressionProgressListener: CompressionProgressListener,
162
189
  duration: Long,
163
- rotation: Int
190
+ rotation: Int,
191
+ location: String?,
164
192
  ): Result {
165
193
  // Check if newWidth and newHeight are valid
166
194
  if (newWidth != 0 && newHeight != 0) {
167
195
  // Create a cache file for the compressed video
168
196
  val cacheFile = File(destination)
169
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
+
170
202
  try {
171
203
  // MediaCodec accesses encoder and decoder components and processes the new video
172
204
  // input to generate a compressed/smaller size video
173
205
  val bufferInfo = MediaCodec.BufferInfo()
174
206
 
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
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.
182
212
  val videoIndex = findTrack(extractor, isVideo = true)
183
213
 
184
214
  extractor.selectTrack(videoIndex)
185
215
  extractor.seekTo(0, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
186
216
  val inputFormat = extractor.getTrackFormat(videoIndex)
187
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
+
188
234
  val outputFormat: MediaFormat =
189
235
  MediaFormat.createVideoFormat(MIME_TYPE, newWidth, newHeight)
190
236
 
@@ -196,16 +242,31 @@ object Compressor {
196
242
  outputFrameRate,
197
243
  )
198
244
 
199
- val decoder: MediaCodec
200
-
201
245
  // Check if QTI hardware acceleration is available
202
246
  val hasQTI = hasQTI()
203
247
 
204
- // Prepare the video encoder
205
- 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
+ }
206
262
 
207
- val inputSurface: InputSurface
208
- 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
209
270
 
210
271
  try {
211
272
  var inputDone = false
@@ -213,32 +274,58 @@ object Compressor {
213
274
 
214
275
  var videoTrackIndex = -5
215
276
 
216
- 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
217
299
  inputSurface.makeCurrent()
218
300
  // Move to executing state
219
301
  encoder.start()
220
302
 
221
- outputSurface = OutputSurface()
303
+ val outputSurface = OutputSurface()
304
+ outputSurfaceRef = outputSurface
222
305
 
223
- decoder = prepareDecoder(inputFormat, outputSurface)
306
+ val decoder = prepareDecoder(inputFormat, outputSurface)
307
+ decoderRef = decoder
224
308
 
225
309
  // Move to executing state
226
310
  decoder.start()
227
311
 
228
312
  while (!outputDone) {
229
313
  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) {
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
237
325
  val inputBuffer = decoder.getInputBuffer(inputBufferIndex)
238
326
  val chunkSize = extractor.readSampleData(inputBuffer!!, 0)
239
327
  when {
240
328
  chunkSize < 0 -> {
241
-
242
329
  decoder.queueInputBuffer(
243
330
  inputBufferIndex,
244
331
  0,
@@ -249,7 +336,6 @@ object Compressor {
249
336
  inputDone = true
250
337
  }
251
338
  else -> {
252
-
253
339
  decoder.queueInputBuffer(
254
340
  inputBufferIndex,
255
341
  0,
@@ -258,15 +344,12 @@ object Compressor {
258
344
  0
259
345
  )
260
346
  extractor.advance()
261
-
262
347
  }
263
348
  }
264
- }
265
-
266
- } else if (index == -1) { //end of file
267
- val inputBufferIndex =
268
- decoder.dequeueInputBuffer(MEDIACODEC_TIMEOUT_DEFAULT)
269
- if (inputBufferIndex >= 0) {
349
+ } else if (index == -1) { //end of file
350
+ val inputBufferIndex =
351
+ decoder.dequeueInputBuffer(0L)
352
+ if (inputBufferIndex < 0) break@feedLoop
270
353
  decoder.queueInputBuffer(
271
354
  inputBufferIndex,
272
355
  0,
@@ -275,6 +358,9 @@ object Compressor {
275
358
  MediaCodec.BUFFER_FLAG_END_OF_STREAM
276
359
  )
277
360
  inputDone = true
361
+ } else {
362
+ // Different track type at head of extractor (audio etc.).
363
+ break@feedLoop
278
364
  }
279
365
  }
280
366
  }
@@ -353,7 +439,27 @@ object Compressor {
353
439
  }
354
440
  decoderStatus < 0 -> throw RuntimeException("unexpected result from decoder.dequeueOutputBuffer: $decoderStatus")
355
441
  else -> {
356
- 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
+ }
357
463
 
358
464
  decoder.releaseOutputBuffer(decoderStatus, doRender)
359
465
  if (doRender) {
@@ -392,16 +498,32 @@ object Compressor {
392
498
 
393
499
  } catch (exception: Throwable) {
394
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()
395
517
  return Result(id, success = false, failureMessage = exception.message)
396
518
  }
397
519
 
398
520
  // Release resources
399
521
  dispose(
400
522
  videoIndex,
401
- decoder,
523
+ decoderRef,
402
524
  encoder,
403
- inputSurface,
404
- outputSurface,
525
+ inputSurfaceRef,
526
+ outputSurfaceRef,
405
527
  extractor
406
528
  )
407
529
 
@@ -418,18 +540,27 @@ object Compressor {
418
540
  mediaMuxer.finishMovie()
419
541
  } catch (e: Throwable) {
420
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()
421
546
  return Result(id, success = false, failureMessage = e.message ?: "Failed to finalize compressed video")
422
547
  }
423
548
 
424
549
  } catch (exception: Throwable) {
425
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()
426
555
  return Result(id, success = false, failureMessage = exception.message)
427
556
  }
428
557
 
429
558
  var resultFile = cacheFile
430
559
 
431
560
  try {
432
- // Keep default outputs browser-compatible by moving the MP4 metadata before media data.
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.
433
564
  val targetFile = streamableFile?.let { File(it) } ?: getStreamableOutputFile(cacheFile)
434
565
  val outputFile = if (targetFile.absolutePath == cacheFile.absolutePath) {
435
566
  getStreamableOutputFile(cacheFile)
@@ -547,25 +678,109 @@ object Compressor {
547
678
  }
548
679
 
549
680
  // 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) {
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) {
559
765
  MediaCodec.createByCodecName("c2.android.avc.encoder")
560
766
  } else {
561
767
  MediaCodec.createEncoderByType(MIME_TYPE)
562
768
  }
563
- encoder.configure(
564
- outputFormat, null, null,
565
- MediaCodec.CONFIGURE_FLAG_ENCODE
566
- )
769
+ }
567
770
 
568
- 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
569
784
  }
570
785
 
571
786
  // Function to prepare the video decoder
@@ -573,42 +788,71 @@ object Compressor {
573
788
  inputFormat: MediaFormat,
574
789
  outputSurface: OutputSurface,
575
790
  ): 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)
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
+ }
581
804
 
582
- // val decoder = if (hasQTI) {
583
- // MediaCodec.createByCodecName("c2.android.avc.decoder")
584
- //} else {
805
+ val decoder = MediaCodec.createDecoderByType(resolvedMime)
585
806
 
586
- val decoder = MediaCodec.createDecoderByType(inputFormat.getString(MediaFormat.KEY_MIME)!!)
587
- //}
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
+ }
588
815
 
589
- decoder.configure(inputFormat, outputSurface.getSurface(), null, 0)
816
+ Log.i("Compressor", "decoder selected: ${decoder.name} mime=$resolvedMime")
590
817
 
591
818
  return decoder
592
819
  }
593
820
 
594
- // 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.
595
832
  private fun dispose(
596
833
  videoIndex: Int,
597
- decoder: MediaCodec,
834
+ decoder: MediaCodec?,
598
835
  encoder: MediaCodec,
599
- inputSurface: InputSurface,
600
- outputSurface: OutputSurface,
836
+ inputSurface: InputSurface?,
837
+ outputSurface: OutputSurface?,
601
838
  extractor: MediaExtractor
602
839
  ) {
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()
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) }
613
857
  }
614
858
  }
@@ -49,16 +49,19 @@ object CompressorUtils {
49
49
  }
50
50
 
51
51
  /**
52
- * Set up an Mp4Movie with rotation and cache file.
52
+ * Set up an Mp4Movie with rotation, cache file and optional ISO 6709
53
+ * location string forwarded from the source video.
53
54
  */
54
55
  fun setUpMP4Movie(
55
56
  rotation: Int,
56
57
  cacheFile: File,
58
+ location: String? = null,
57
59
  ): Mp4Movie {
58
60
  val movie = Mp4Movie()
59
61
  movie.apply {
60
62
  setCacheFile(cacheFile)
61
63
  setRotation(rotation)
64
+ setLocation(location)
62
65
  }
63
66
  return movie
64
67
  }
@@ -71,6 +74,7 @@ object CompressorUtils {
71
74
  outputFormat: MediaFormat,
72
75
  newBitrate: Int,
73
76
  targetFrameRate: Int,
77
+ applyThroughputTuning: Boolean = true,
74
78
  ) {
75
79
  val newFrameRate = targetFrameRate.coerceAtLeast(1)
76
80
  val iFrameInterval = getIFrameIntervalRate(inputFormat)
@@ -84,10 +88,25 @@ object CompressorUtils {
84
88
  setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameInterval)
85
89
  // Bitrate in bits per second
86
90
  setInteger(MediaFormat.KEY_BIT_RATE, newBitrate)
87
- setInteger(
88
- MediaFormat.KEY_BITRATE_MODE,
89
- MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR
90
- )
91
+
92
+ // Throughput tuning. Some encoders reject these keys at configure() time,
93
+ // so the caller drops them (applyThroughputTuning = false) and reconfigures
94
+ // with default rate control on a fallback pass — see Compressor.prepareEncoder.
95
+ if (applyThroughputTuning) {
96
+ // VBR transcodes ~10-20% faster than CBR by skipping rate-control overhead
97
+ // on low-motion frames; quality stays equivalent for short-form video.
98
+ setInteger(
99
+ MediaFormat.KEY_BITRATE_MODE,
100
+ MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR
101
+ )
102
+ // Hint the hardware codec to run as fast as it can (not throttled to
103
+ // realtime playback) and at the highest scheduling priority. These keys
104
+ // unlock full throughput on Qualcomm / Exynos / MTK SoCs that accept them.
105
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
106
+ setInteger(MediaFormat.KEY_PRIORITY, 0)
107
+ setInteger(MediaFormat.KEY_OPERATING_RATE, Short.MAX_VALUE.toInt())
108
+ }
109
+ }
91
110
 
92
111
  getColorStandard(inputFormat)?.let {
93
112
  setInteger(MediaFormat.KEY_COLOR_STANDARD, it)