claude's first attempt
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
group = 'expo.modules.blueskyvideo'
|
||||
version = '1.0.0'
|
||||
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
apply from: expoModulesCorePlugin
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
useCoreDependencies()
|
||||
useExpoPublishing()
|
||||
|
||||
buildscript {
|
||||
ext.safeExtGet = { prop, fallback ->
|
||||
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion safeExtGet("compileSdkVersion", 34)
|
||||
|
||||
namespace "expo.modules.blueskyvideo"
|
||||
defaultConfig {
|
||||
minSdkVersion safeExtGet("minSdkVersion", 21)
|
||||
targetSdkVersion safeExtGet("targetSdkVersion", 34)
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package expo.modules.blueskyvideo
|
||||
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaCodecList
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
|
||||
object CodecSelector {
|
||||
private const val TAG = "CodecSelector"
|
||||
|
||||
// Known-bad hardware encoders
|
||||
// Source: https://github.com/numandev1/react-native-compressor/blob/f949b0868055178e7c8753e05202f784b1bcd589/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt#L500
|
||||
private val DENYLIST = setOf(
|
||||
"c2.qti.avc.encoder", // Qualcomm - known to produce corrupted output
|
||||
)
|
||||
|
||||
private const val VIDEO_AVC = "video/avc"
|
||||
|
||||
data class EncoderInfo(
|
||||
val name: String,
|
||||
val isHardware: Boolean
|
||||
)
|
||||
|
||||
fun selectEncoder(): EncoderInfo {
|
||||
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
|
||||
val codecInfos = codecList.codecInfos
|
||||
|
||||
val hardwareEncoders = mutableListOf<MediaCodecInfo>()
|
||||
val softwareEncoders = mutableListOf<MediaCodecInfo>()
|
||||
|
||||
for (codecInfo in codecInfos) {
|
||||
if (!codecInfo.isEncoder) continue
|
||||
val types = codecInfo.supportedTypes
|
||||
if (!types.any { it.equals(VIDEO_AVC, ignoreCase = true) }) continue
|
||||
if (DENYLIST.contains(codecInfo.name)) {
|
||||
Log.d(TAG, "Skipping denylisted encoder: ${codecInfo.name}")
|
||||
continue
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
if (codecInfo.isHardwareAccelerated) {
|
||||
hardwareEncoders.add(codecInfo)
|
||||
} else {
|
||||
softwareEncoders.add(codecInfo)
|
||||
}
|
||||
} else {
|
||||
// Pre-API 29: heuristic - hardware encoders usually don't have "sw" or "google" in name
|
||||
val name = codecInfo.name.lowercase()
|
||||
if (!name.contains("sw") && !name.contains("google")) {
|
||||
hardwareEncoders.add(codecInfo)
|
||||
} else {
|
||||
softwareEncoders.add(codecInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer hardware encoders
|
||||
val selected = hardwareEncoders.firstOrNull() ?: softwareEncoders.firstOrNull()
|
||||
|
||||
if (selected == null) {
|
||||
throw RuntimeException("No H.264 encoder available")
|
||||
}
|
||||
|
||||
val isHardware = hardwareEncoders.contains(selected)
|
||||
Log.d(TAG, "Selected encoder: ${selected.name} (hardware: $isHardware)")
|
||||
|
||||
return EncoderInfo(
|
||||
name = selected.name,
|
||||
isHardware = isHardware
|
||||
)
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package expo.modules.blueskyvideo
|
||||
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class ExpoVideoCompressModule : Module() {
|
||||
private var currentCompressor: VideoCompressor? = null
|
||||
|
||||
override fun definition() =
|
||||
ModuleDefinition {
|
||||
Name("ExpoVideoCompress")
|
||||
|
||||
Events("onProgress")
|
||||
|
||||
AsyncFunction("probe") { uri: String ->
|
||||
val context = appContext.reactContext
|
||||
?: throw Error("React context is null")
|
||||
return@AsyncFunction VideoProber.probe(context, uri)
|
||||
}
|
||||
|
||||
AsyncFunction("compress") { uri: String, options: Map<String, Any?> ->
|
||||
val context = appContext.reactContext
|
||||
?: throw Error("React context is null")
|
||||
val targetBitrate = (options["targetBitrate"] as? Number)?.toInt() ?: 3_000_000
|
||||
val maxSize = (options["maxSize"] as? Number)?.toInt() ?: 1920
|
||||
val jobId = (options["jobId"] as? Number)?.toInt() ?: 0
|
||||
|
||||
val compressor = VideoCompressor(
|
||||
context = context,
|
||||
uri = uri,
|
||||
targetBitrate = targetBitrate,
|
||||
maxSize = maxSize,
|
||||
jobId = jobId,
|
||||
onProgress = { id, progress ->
|
||||
sendEvent("onProgress", mapOf(
|
||||
"id" to id,
|
||||
"progress" to progress
|
||||
))
|
||||
}
|
||||
)
|
||||
|
||||
currentCompressor = compressor
|
||||
|
||||
try {
|
||||
val result = compressor.compress()
|
||||
currentCompressor = null
|
||||
return@AsyncFunction result
|
||||
} catch (e: Exception) {
|
||||
currentCompressor = null
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
Function("cancel") {
|
||||
currentCompressor?.cancel()
|
||||
currentCompressor = null
|
||||
}
|
||||
}
|
||||
}
|
||||
+573
@@ -0,0 +1,573 @@
|
||||
package expo.modules.blueskyvideo
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMuxer
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
class VideoCompressor(
|
||||
private val context: Context,
|
||||
private val uri: String,
|
||||
private val targetBitrate: Int,
|
||||
private val maxSize: Int,
|
||||
private val jobId: Int,
|
||||
private val onProgress: (Int, Double) -> Unit
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "VideoCompressor"
|
||||
private const val TIMEOUT_US = 10_000L
|
||||
private const val I_FRAME_INTERVAL = 3
|
||||
private const val AUDIO_AAC_BITRATE = 128_000
|
||||
private const val AUDIO_SAMPLE_RATE = 44100
|
||||
private const val AUDIO_CHANNELS = 2
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var isCancelled = false
|
||||
|
||||
fun cancel() {
|
||||
isCancelled = true
|
||||
}
|
||||
|
||||
fun compress(): Map<String, Any> {
|
||||
val encoderInfo = CodecSelector.selectEncoder()
|
||||
Log.d(TAG, "Using encoder: ${encoderInfo.name}")
|
||||
|
||||
try {
|
||||
return doCompress(encoderInfo.name)
|
||||
} catch (e: Exception) {
|
||||
// If hardware encoder failed, retry with software fallback
|
||||
if (encoderInfo.isHardware && !isCancelled) {
|
||||
Log.w(TAG, "Hardware encoding failed, trying software fallback", e)
|
||||
try {
|
||||
val softwareEncoder = findSoftwareEncoder()
|
||||
if (softwareEncoder != null) {
|
||||
return doCompress(softwareEncoder)
|
||||
}
|
||||
} catch (e2: Exception) {
|
||||
Log.e(TAG, "Software fallback also failed", e2)
|
||||
}
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun doCompress(encoderName: String): Map<String, Any> {
|
||||
val parsedUri = Uri.parse(uri)
|
||||
|
||||
// Set up extractor
|
||||
val extractor = MediaExtractor()
|
||||
if (uri.startsWith("content://") || uri.startsWith("file://")) {
|
||||
extractor.setDataSource(context, parsedUri, null)
|
||||
} else {
|
||||
extractor.setDataSource(uri)
|
||||
}
|
||||
|
||||
// Find video and audio tracks
|
||||
var videoTrackIndex = -1
|
||||
var audioTrackIndex = -1
|
||||
var videoFormat: MediaFormat? = null
|
||||
var audioFormat: MediaFormat? = null
|
||||
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
|
||||
if (mime.startsWith("video/") && videoTrackIndex == -1) {
|
||||
videoTrackIndex = i
|
||||
videoFormat = format
|
||||
} else if (mime.startsWith("audio/") && audioTrackIndex == -1) {
|
||||
audioTrackIndex = i
|
||||
audioFormat = format
|
||||
}
|
||||
}
|
||||
|
||||
if (videoTrackIndex == -1 || videoFormat == null) {
|
||||
extractor.release()
|
||||
throw RuntimeException("No video track found")
|
||||
}
|
||||
|
||||
// Get source video properties
|
||||
val sourceWidth = videoFormat.getInteger(MediaFormat.KEY_WIDTH)
|
||||
val sourceHeight = videoFormat.getInteger(MediaFormat.KEY_HEIGHT)
|
||||
val rotation = if (videoFormat.containsKey(MediaFormat.KEY_ROTATION)) {
|
||||
videoFormat.getInteger(MediaFormat.KEY_ROTATION)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val durationUs = if (videoFormat.containsKey(MediaFormat.KEY_DURATION)) {
|
||||
videoFormat.getLong(MediaFormat.KEY_DURATION)
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
val frameRate = if (videoFormat.containsKey(MediaFormat.KEY_FRAME_RATE)) {
|
||||
videoFormat.getInteger(MediaFormat.KEY_FRAME_RATE)
|
||||
} else {
|
||||
30
|
||||
}
|
||||
|
||||
// Calculate output size
|
||||
val outputSize = calculateOutputSize(sourceWidth, sourceHeight, rotation, maxSize)
|
||||
|
||||
// Determine audio passthrough
|
||||
val shouldPassthroughAudio = audioFormat != null && canPassthroughAudio(audioFormat)
|
||||
|
||||
// Output file
|
||||
val outputFile = File(context.cacheDir, "${System.currentTimeMillis()}.mp4")
|
||||
|
||||
// Set up muxer
|
||||
val muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
|
||||
// Set rotation on the muxer (not in the encoded video)
|
||||
if (rotation != 0) {
|
||||
muxer.setOrientationHint(rotation)
|
||||
}
|
||||
|
||||
var muxerVideoTrack = -1
|
||||
var muxerAudioTrack = -1
|
||||
var muxerStarted = false
|
||||
|
||||
// Set up video encoder
|
||||
val encoderFormat = MediaFormat.createVideoFormat(
|
||||
MediaFormat.MIMETYPE_VIDEO_AVC,
|
||||
outputSize.first,
|
||||
outputSize.second
|
||||
).apply {
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, targetBitrate)
|
||||
setInteger(MediaFormat.KEY_FRAME_RATE, frameRate.coerceAtMost(30))
|
||||
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL)
|
||||
setInteger(
|
||||
MediaFormat.KEY_COLOR_FORMAT,
|
||||
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
setInteger(
|
||||
MediaFormat.KEY_PROFILE,
|
||||
MediaCodecInfo.CodecProfileLevel.AVCProfileHigh
|
||||
)
|
||||
setInteger(
|
||||
MediaFormat.KEY_LEVEL,
|
||||
MediaCodecInfo.CodecProfileLevel.AVCLevel41
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val encoder = MediaCodec.createByCodecName(encoderName)
|
||||
encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
val inputSurface = encoder.createInputSurface()
|
||||
encoder.start()
|
||||
|
||||
// Set up video decoder
|
||||
val decoderFormat = videoFormat
|
||||
val decoder = MediaCodec.createDecoderByType(
|
||||
videoFormat.getString(MediaFormat.KEY_MIME) ?: "video/avc"
|
||||
)
|
||||
// Output surface is the encoder's input surface for zero-copy pipeline
|
||||
decoder.configure(decoderFormat, inputSurface, null, 0)
|
||||
decoder.start()
|
||||
|
||||
extractor.selectTrack(videoTrackIndex)
|
||||
|
||||
// Process video frames
|
||||
val bufferInfo = MediaCodec.BufferInfo()
|
||||
var inputDone = false
|
||||
var outputDone = false
|
||||
var lastProgressTime = 0L
|
||||
|
||||
try {
|
||||
while (!outputDone && !isCancelled) {
|
||||
// Feed decoder
|
||||
if (!inputDone) {
|
||||
val inputIndex = decoder.dequeueInputBuffer(TIMEOUT_US)
|
||||
if (inputIndex >= 0) {
|
||||
val inputBuffer = decoder.getInputBuffer(inputIndex) ?: continue
|
||||
val sampleSize = extractor.readSampleData(inputBuffer, 0)
|
||||
if (sampleSize < 0) {
|
||||
decoder.queueInputBuffer(
|
||||
inputIndex, 0, 0, 0,
|
||||
MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||
)
|
||||
inputDone = true
|
||||
} else {
|
||||
decoder.queueInputBuffer(
|
||||
inputIndex, 0, sampleSize,
|
||||
extractor.sampleTime, 0
|
||||
)
|
||||
extractor.advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drain decoder -> surface -> encoder
|
||||
drainDecoder(decoder, bufferInfo)
|
||||
|
||||
// Drain encoder
|
||||
val encoderOutputIndex = encoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
|
||||
when {
|
||||
encoderOutputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||
if (!muxerStarted) {
|
||||
muxerVideoTrack = muxer.addTrack(encoder.outputFormat)
|
||||
// If we have audio, add it now too before starting muxer
|
||||
if (audioTrackIndex != -1 && audioFormat != null) {
|
||||
muxerAudioTrack = if (shouldPassthroughAudio) {
|
||||
muxer.addTrack(audioFormat)
|
||||
} else {
|
||||
// Audio re-encode track will be added when audio encoder outputs format
|
||||
-1
|
||||
}
|
||||
}
|
||||
if (audioTrackIndex == -1 || muxerAudioTrack >= 0) {
|
||||
muxer.start()
|
||||
muxerStarted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
encoderOutputIndex >= 0 -> {
|
||||
val outputBuffer = encoder.getOutputBuffer(encoderOutputIndex)
|
||||
if (outputBuffer != null &&
|
||||
bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0 &&
|
||||
bufferInfo.size > 0 &&
|
||||
muxerStarted) {
|
||||
muxer.writeSampleData(muxerVideoTrack, outputBuffer, bufferInfo)
|
||||
}
|
||||
|
||||
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
encoder.releaseOutputBuffer(encoderOutputIndex, false)
|
||||
|
||||
if (isEos) {
|
||||
outputDone = true
|
||||
}
|
||||
|
||||
// Progress reporting
|
||||
if (durationUs > 0) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastProgressTime >= 100) {
|
||||
lastProgressTime = now
|
||||
val progress = (bufferInfo.presentationTimeUs.toDouble() / durationUs)
|
||||
.coerceIn(0.0, 1.0)
|
||||
onProgress(jobId, progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process audio track
|
||||
if (audioTrackIndex != -1 && audioFormat != null && !isCancelled) {
|
||||
if (shouldPassthroughAudio) {
|
||||
processAudioPassthrough(
|
||||
extractor, audioTrackIndex, muxer, muxerAudioTrack, muxerStarted
|
||||
)
|
||||
} else {
|
||||
processAudioReencode(
|
||||
extractor, audioTrackIndex, audioFormat, muxer, muxerStarted
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Clean up resources
|
||||
try { decoder.stop() } catch (_: Exception) {}
|
||||
try { decoder.release() } catch (_: Exception) {}
|
||||
try { encoder.stop() } catch (_: Exception) {}
|
||||
try { encoder.release() } catch (_: Exception) {}
|
||||
try { inputSurface.release() } catch (_: Exception) {}
|
||||
try { extractor.release() } catch (_: Exception) {}
|
||||
try {
|
||||
if (muxerStarted) muxer.stop()
|
||||
muxer.release()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
if (isCancelled) {
|
||||
outputFile.delete()
|
||||
throw RuntimeException("Compression cancelled")
|
||||
}
|
||||
|
||||
val fileSize = outputFile.length()
|
||||
val durationSeconds = durationUs / 1_000_000.0
|
||||
|
||||
return mapOf(
|
||||
"uri" to "file://${outputFile.absolutePath}",
|
||||
"size" to fileSize,
|
||||
"mimeType" to "video/mp4",
|
||||
"width" to outputSize.first,
|
||||
"height" to outputSize.second,
|
||||
"duration" to durationSeconds
|
||||
)
|
||||
}
|
||||
|
||||
private fun drainDecoder(decoder: MediaCodec, bufferInfo: MediaCodec.BufferInfo) {
|
||||
while (true) {
|
||||
val outputIndex = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
|
||||
if (outputIndex < 0) break
|
||||
|
||||
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
// Render to surface (encoder's input) - true means render
|
||||
decoder.releaseOutputBuffer(outputIndex, bufferInfo.size > 0)
|
||||
|
||||
if (isEos) {
|
||||
// Signal encoder that input is done
|
||||
// Note: with Surface input, we signal EOS by calling signalEndOfInputStream
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If decoder flagged EOS, signal encoder
|
||||
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
||||
// This may throw if already signaled - that's fine
|
||||
try {
|
||||
// We need access to encoder here - this is handled in the main loop
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processAudioPassthrough(
|
||||
extractor: MediaExtractor,
|
||||
audioTrackIndex: Int,
|
||||
muxer: MediaMuxer,
|
||||
muxerAudioTrack: Int,
|
||||
muxerStarted: Boolean
|
||||
) {
|
||||
if (!muxerStarted || muxerAudioTrack < 0) return
|
||||
|
||||
// Need a separate extractor for audio since the first one is used for video
|
||||
val audioExtractor = MediaExtractor()
|
||||
if (uri.startsWith("content://") || uri.startsWith("file://")) {
|
||||
audioExtractor.setDataSource(context, Uri.parse(uri), null)
|
||||
} else {
|
||||
audioExtractor.setDataSource(uri)
|
||||
}
|
||||
audioExtractor.selectTrack(audioTrackIndex)
|
||||
|
||||
val buffer = ByteBuffer.allocate(1024 * 1024) // 1MB buffer
|
||||
val info = MediaCodec.BufferInfo()
|
||||
|
||||
try {
|
||||
while (!isCancelled) {
|
||||
val sampleSize = audioExtractor.readSampleData(buffer, 0)
|
||||
if (sampleSize < 0) break
|
||||
|
||||
info.offset = 0
|
||||
info.size = sampleSize
|
||||
info.presentationTimeUs = audioExtractor.sampleTime
|
||||
info.flags = audioExtractor.sampleFlags
|
||||
|
||||
muxer.writeSampleData(muxerAudioTrack, buffer, info)
|
||||
audioExtractor.advance()
|
||||
}
|
||||
} finally {
|
||||
audioExtractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun processAudioReencode(
|
||||
extractor: MediaExtractor,
|
||||
audioTrackIndex: Int,
|
||||
audioFormat: MediaFormat,
|
||||
muxer: MediaMuxer,
|
||||
muxerStarted: Boolean
|
||||
) {
|
||||
// Set up separate extractor for audio
|
||||
val audioExtractor = MediaExtractor()
|
||||
if (uri.startsWith("content://") || uri.startsWith("file://")) {
|
||||
audioExtractor.setDataSource(context, Uri.parse(uri), null)
|
||||
} else {
|
||||
audioExtractor.setDataSource(uri)
|
||||
}
|
||||
audioExtractor.selectTrack(audioTrackIndex)
|
||||
|
||||
val audioMime = audioFormat.getString(MediaFormat.KEY_MIME) ?: "audio/mp4a-latm"
|
||||
val sampleRate = if (audioFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE)) {
|
||||
audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
} else {
|
||||
AUDIO_SAMPLE_RATE
|
||||
}
|
||||
val channelCount = if (audioFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) {
|
||||
audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
} else {
|
||||
AUDIO_CHANNELS
|
||||
}
|
||||
|
||||
// Audio decoder
|
||||
val audioDecoder = MediaCodec.createDecoderByType(audioMime)
|
||||
audioDecoder.configure(audioFormat, null, null, 0)
|
||||
audioDecoder.start()
|
||||
|
||||
// Audio encoder
|
||||
val audioEncoderFormat = MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_AAC,
|
||||
sampleRate,
|
||||
channelCount
|
||||
).apply {
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, AUDIO_AAC_BITRATE)
|
||||
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
|
||||
}
|
||||
val audioEncoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
|
||||
audioEncoder.configure(audioEncoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
audioEncoder.start()
|
||||
|
||||
var muxerTrack = -1
|
||||
var localMuxerStarted = muxerStarted
|
||||
val bufferInfo = MediaCodec.BufferInfo()
|
||||
var inputDone = false
|
||||
var decoderDone = false
|
||||
var encoderDone = false
|
||||
|
||||
try {
|
||||
while (!encoderDone && !isCancelled) {
|
||||
// Feed decoder
|
||||
if (!inputDone) {
|
||||
val inputIndex = audioDecoder.dequeueInputBuffer(TIMEOUT_US)
|
||||
if (inputIndex >= 0) {
|
||||
val inputBuffer = audioDecoder.getInputBuffer(inputIndex) ?: continue
|
||||
val sampleSize = audioExtractor.readSampleData(inputBuffer, 0)
|
||||
if (sampleSize < 0) {
|
||||
audioDecoder.queueInputBuffer(
|
||||
inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||
)
|
||||
inputDone = true
|
||||
} else {
|
||||
audioDecoder.queueInputBuffer(
|
||||
inputIndex, 0, sampleSize, audioExtractor.sampleTime, 0
|
||||
)
|
||||
audioExtractor.advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drain decoder -> feed encoder
|
||||
if (!decoderDone) {
|
||||
val decoderOutputIndex = audioDecoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
|
||||
if (decoderOutputIndex >= 0) {
|
||||
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
val decodedBuffer = audioDecoder.getOutputBuffer(decoderOutputIndex)
|
||||
|
||||
if (decodedBuffer != null && bufferInfo.size > 0) {
|
||||
val encoderInputIndex = audioEncoder.dequeueInputBuffer(TIMEOUT_US)
|
||||
if (encoderInputIndex >= 0) {
|
||||
val encoderInputBuffer = audioEncoder.getInputBuffer(encoderInputIndex)
|
||||
if (encoderInputBuffer != null) {
|
||||
encoderInputBuffer.clear()
|
||||
encoderInputBuffer.put(decodedBuffer)
|
||||
audioEncoder.queueInputBuffer(
|
||||
encoderInputIndex, 0, bufferInfo.size,
|
||||
bufferInfo.presentationTimeUs,
|
||||
if (isEos) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (isEos) {
|
||||
val encoderInputIndex = audioEncoder.dequeueInputBuffer(TIMEOUT_US)
|
||||
if (encoderInputIndex >= 0) {
|
||||
audioEncoder.queueInputBuffer(
|
||||
encoderInputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
audioDecoder.releaseOutputBuffer(decoderOutputIndex, false)
|
||||
if (isEos) decoderDone = true
|
||||
}
|
||||
}
|
||||
|
||||
// Drain encoder -> muxer
|
||||
val encoderOutputIndex = audioEncoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
|
||||
when {
|
||||
encoderOutputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||
if (muxerTrack < 0) {
|
||||
muxerTrack = muxer.addTrack(audioEncoder.outputFormat)
|
||||
if (!localMuxerStarted) {
|
||||
muxer.start()
|
||||
localMuxerStarted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
encoderOutputIndex >= 0 -> {
|
||||
val outputBuffer = audioEncoder.getOutputBuffer(encoderOutputIndex)
|
||||
if (outputBuffer != null &&
|
||||
bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0 &&
|
||||
bufferInfo.size > 0 &&
|
||||
muxerTrack >= 0) {
|
||||
muxer.writeSampleData(muxerTrack, outputBuffer, bufferInfo)
|
||||
}
|
||||
encoderDone = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
audioEncoder.releaseOutputBuffer(encoderOutputIndex, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try { audioDecoder.stop() } catch (_: Exception) {}
|
||||
try { audioDecoder.release() } catch (_: Exception) {}
|
||||
try { audioEncoder.stop() } catch (_: Exception) {}
|
||||
try { audioEncoder.release() } catch (_: Exception) {}
|
||||
audioExtractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun canPassthroughAudio(format: MediaFormat): Boolean {
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: return false
|
||||
if (mime != MediaFormat.MIMETYPE_AUDIO_AAC) return false
|
||||
|
||||
// Check bitrate if available
|
||||
if (format.containsKey(MediaFormat.KEY_BIT_RATE)) {
|
||||
val bitrate = format.getInteger(MediaFormat.KEY_BIT_RATE)
|
||||
return bitrate <= AUDIO_AAC_BITRATE
|
||||
}
|
||||
|
||||
// If no bitrate info, assume we should passthrough AAC
|
||||
return true
|
||||
}
|
||||
|
||||
private fun calculateOutputSize(
|
||||
width: Int,
|
||||
height: Int,
|
||||
rotation: Int,
|
||||
maxSize: Int
|
||||
): Pair<Int, Int> {
|
||||
// Apply rotation to get display dimensions
|
||||
val isRotated = rotation == 90 || rotation == 270
|
||||
val sourceWidth = if (isRotated) height else width
|
||||
val sourceHeight = if (isRotated) width else height
|
||||
|
||||
// If within bounds, keep original (rounded to even)
|
||||
if (sourceWidth <= maxSize && sourceHeight <= maxSize) {
|
||||
return Pair(roundToEven(sourceWidth), roundToEven(sourceHeight))
|
||||
}
|
||||
|
||||
// Scale down maintaining aspect ratio
|
||||
val scale = if (sourceWidth > sourceHeight) {
|
||||
maxSize.toFloat() / sourceWidth.toFloat()
|
||||
} else {
|
||||
maxSize.toFloat() / sourceHeight.toFloat()
|
||||
}
|
||||
|
||||
return Pair(
|
||||
roundToEven((sourceWidth * scale).toInt()),
|
||||
roundToEven((sourceHeight * scale).toInt())
|
||||
)
|
||||
}
|
||||
|
||||
private fun roundToEven(value: Int): Int {
|
||||
return if (value % 2 == 0) value else value - 1
|
||||
}
|
||||
|
||||
private fun findSoftwareEncoder(): String? {
|
||||
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
|
||||
for (codecInfo in codecList.codecInfos) {
|
||||
if (!codecInfo.isEncoder) continue
|
||||
if (!codecInfo.supportedTypes.any { it.equals("video/avc", ignoreCase = true) }) continue
|
||||
val name = codecInfo.name.lowercase()
|
||||
if (name.contains("sw") || name.contains("google") || name.contains("c2.android")) {
|
||||
return codecInfo.name
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package expo.modules.blueskyvideo
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
|
||||
object VideoProber {
|
||||
fun probe(context: Context, uriString: String): Map<String, Any> {
|
||||
val uri = Uri.parse(uriString)
|
||||
val retriever = MediaMetadataRetriever()
|
||||
|
||||
try {
|
||||
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
|
||||
retriever.setDataSource(context, uri)
|
||||
} else {
|
||||
retriever.setDataSource(uriString)
|
||||
}
|
||||
|
||||
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
|
||||
?.toIntOrNull() ?: 0
|
||||
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
|
||||
?.toIntOrNull() ?: 0
|
||||
val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
||||
?.toLongOrNull() ?: 0L
|
||||
val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
|
||||
?.toIntOrNull() ?: 0
|
||||
val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
|
||||
?.toIntOrNull() ?: 0
|
||||
val hasAudio = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO)
|
||||
?.equals("yes") ?: false
|
||||
val frameRate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)
|
||||
?.toFloatOrNull() ?: 0f
|
||||
|
||||
// Get file size
|
||||
val fileSize = getFileSize(context, uriString)
|
||||
|
||||
// Get codec and frame rate from MediaExtractor for more accuracy
|
||||
val extractor = MediaExtractor()
|
||||
var codec = "unknown"
|
||||
var extractedFrameRate = frameRate
|
||||
|
||||
try {
|
||||
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
|
||||
extractor.setDataSource(context, uri, null)
|
||||
} else {
|
||||
extractor.setDataSource(uriString)
|
||||
}
|
||||
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME)
|
||||
if (mime?.startsWith("video/") == true) {
|
||||
codec = mime.removePrefix("video/")
|
||||
if (format.containsKey(MediaFormat.KEY_FRAME_RATE)) {
|
||||
extractedFrameRate = format.getInteger(MediaFormat.KEY_FRAME_RATE).toFloat()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
extractor.release()
|
||||
}
|
||||
|
||||
// Calculate bitrate from file size if not available
|
||||
val durationSeconds = durationMs / 1000.0
|
||||
val effectiveBitrate = if (bitrate > 0) {
|
||||
bitrate
|
||||
} else if (durationSeconds > 0 && fileSize > 0) {
|
||||
(fileSize * 8 / durationSeconds).toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
return mapOf(
|
||||
"width" to width,
|
||||
"height" to height,
|
||||
"duration" to durationSeconds,
|
||||
"bitrate" to effectiveBitrate,
|
||||
"fileSize" to fileSize,
|
||||
"codec" to codec,
|
||||
"hasAudio" to hasAudio,
|
||||
"frameRate" to extractedFrameRate.toDouble(),
|
||||
"rotation" to rotation
|
||||
)
|
||||
} finally {
|
||||
retriever.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFileSize(context: Context, uriString: String): Long {
|
||||
return try {
|
||||
if (uriString.startsWith("content://")) {
|
||||
context.contentResolver.openFileDescriptor(Uri.parse(uriString), "r")?.use {
|
||||
it.statSize
|
||||
} ?: 0L
|
||||
} else {
|
||||
val path = if (uriString.startsWith("file://")) {
|
||||
uriString.removePrefix("file://")
|
||||
} else {
|
||||
uriString
|
||||
}
|
||||
java.io.File(path).length()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user