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.
- package/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt +41 -21
- package/android/src/main/java/com/reactnativecompressor/Video/AutoVideoCompression.kt +1 -1
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressionProfile.kt +21 -11
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt +322 -78
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/CompressorUtils.kt +24 -5
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/LocationExtractor.kt +397 -0
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/LocationBox.kt +47 -0
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/MP4Builder.kt +39 -7
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/Mp4Movie.kt +7 -0
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/OutputSurface.kt +30 -4
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressorHelper.kt +24 -1
- package/ios/Video/VideoMain.swift +27 -11
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
//
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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
|
|
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
|
-
|
|
523
|
+
decoderRef,
|
|
402
524
|
encoder,
|
|
403
|
-
|
|
404
|
-
|
|
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
|
|
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(
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
//
|
|
556
|
-
// c2.qti.avc.encoder
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
564
|
-
outputFormat, null, null,
|
|
565
|
-
MediaCodec.CONFIGURE_FLAG_ENCODE
|
|
566
|
-
)
|
|
769
|
+
}
|
|
567
770
|
|
|
568
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
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
|
-
|
|
583
|
-
// MediaCodec.createByCodecName("c2.android.avc.decoder")
|
|
584
|
-
//} else {
|
|
805
|
+
val decoder = MediaCodec.createDecoderByType(resolvedMime)
|
|
585
806
|
|
|
586
|
-
|
|
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
|
-
|
|
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
|
-
|
|
606
|
-
decoder
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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)
|