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.
- package/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt +44 -23
- package/android/src/main/java/com/reactnativecompressor/Utils/createVideoThumbnail.kt +42 -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 +369 -87
- 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/Image/ImageCompressor.swift +3 -2
- package/ios/Utils/CreateVideoThumbnail.swift +40 -21
- package/ios/Utils/Uploader.swift +14 -7
- package/ios/Video/VideoMain.swift +27 -11
- package/lib/commonjs/index.js +1 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/utils/index.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/index.js.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/index.d.ts +1 -0
- package/lib/typescript/src/utils/index.d.ts.map +1 -1
- package/package.json +7 -1
- package/src/index.tsx +1 -0
- 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
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
|
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
|
-
|
|
523
|
+
decoderRef,
|
|
391
524
|
encoder,
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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.
|
|
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(
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
//
|
|
518
|
-
// c2.qti.avc.encoder
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
526
|
-
outputFormat, null, null,
|
|
527
|
-
MediaCodec.CONFIGURE_FLAG_ENCODE
|
|
528
|
-
)
|
|
769
|
+
}
|
|
529
770
|
|
|
530
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
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
|
-
|
|
545
|
-
// MediaCodec.createByCodecName("c2.android.avc.decoder")
|
|
546
|
-
//} else {
|
|
805
|
+
val decoder = MediaCodec.createDecoderByType(resolvedMime)
|
|
547
806
|
|
|
548
|
-
|
|
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
|
-
|
|
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
|
-
|
|
568
|
-
decoder
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
}
|