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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["ios", "android"],
|
||||||
|
"ios": {
|
||||||
|
"modules": ["ExpoVideoCompressModule"]
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"modules": ["expo.modules.blueskyvideo.ExpoVideoCompressModule"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import {type EventSubscription} from 'expo-modules-core'
|
||||||
|
|
||||||
|
import NativeModule from './src/ExpoVideoCompressModule'
|
||||||
|
import {
|
||||||
|
type CompressOptions,
|
||||||
|
type CompressResult,
|
||||||
|
type VideoMetadata,
|
||||||
|
} from './src/types'
|
||||||
|
|
||||||
|
export type {CompressOptions, CompressResult, VideoMetadata}
|
||||||
|
|
||||||
|
let jobIdCounter = 0
|
||||||
|
|
||||||
|
export function probe(uri: string): Promise<VideoMetadata> {
|
||||||
|
return NativeModule.probe(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compress(
|
||||||
|
uri: string,
|
||||||
|
options: CompressOptions,
|
||||||
|
callbacks?: {
|
||||||
|
onProgress?: (progress: number) => void
|
||||||
|
signal?: AbortSignal
|
||||||
|
},
|
||||||
|
): Promise<CompressResult> {
|
||||||
|
const jobId = ++jobIdCounter
|
||||||
|
let subscription: EventSubscription | undefined
|
||||||
|
|
||||||
|
if (callbacks?.signal?.aborted) {
|
||||||
|
return Promise.reject(new DOMException('Aborted', 'AbortError'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<CompressResult>((resolve, reject) => {
|
||||||
|
if (callbacks?.onProgress) {
|
||||||
|
subscription = NativeModule.addListener(
|
||||||
|
'onProgress',
|
||||||
|
(event: {id: number; progress: number}) => {
|
||||||
|
if (event.id === jobId) {
|
||||||
|
callbacks.onProgress!(event.progress)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortHandler = () => {
|
||||||
|
NativeModule.cancel()
|
||||||
|
subscription?.remove()
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callbacks?.signal) {
|
||||||
|
callbacks.signal.addEventListener('abort', abortHandler, {once: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
NativeModule.compress(uri, {...options, jobId})
|
||||||
|
.then(result => {
|
||||||
|
callbacks?.signal?.removeEventListener('abort', abortHandler)
|
||||||
|
subscription?.remove()
|
||||||
|
resolve(result)
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
callbacks?.signal?.removeEventListener('abort', abortHandler)
|
||||||
|
subscription?.remove()
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
Pod::Spec.new do |s|
|
||||||
|
s.name = 'ExpoBlueskyVideoCompress'
|
||||||
|
s.version = '1.0.0'
|
||||||
|
s.summary = 'Hardware-accelerated video compression for Bluesky'
|
||||||
|
s.description = 'Hardware-accelerated video compression using AVAssetReader/Writer on iOS'
|
||||||
|
s.author = ''
|
||||||
|
s.homepage = 'https://github.com/bluesky-social/social-app'
|
||||||
|
s.platforms = { :ios => '15.1' }
|
||||||
|
s.source = { git: '' }
|
||||||
|
s.static_framework = true
|
||||||
|
|
||||||
|
s.dependency 'ExpoModulesCore'
|
||||||
|
|
||||||
|
s.pod_target_xcconfig = {
|
||||||
|
'DEFINES_MODULE' => 'YES',
|
||||||
|
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||||
|
}
|
||||||
|
|
||||||
|
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||||
|
end
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
public class ExpoVideoCompressModule: Module {
|
||||||
|
private var currentCompressor: VideoCompressor?
|
||||||
|
|
||||||
|
public func definition() -> ModuleDefinition {
|
||||||
|
Name("ExpoVideoCompress")
|
||||||
|
|
||||||
|
Events("onProgress")
|
||||||
|
|
||||||
|
AsyncFunction("probe") { (uri: String) -> [String: Any] in
|
||||||
|
let url = URL(string: uri) ?? URL(fileURLWithPath: uri)
|
||||||
|
return try await VideoProber.probe(url: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("compress") { (uri: String, options: [String: Any]) -> [String: Any] in
|
||||||
|
let url = URL(string: uri) ?? URL(fileURLWithPath: uri)
|
||||||
|
let targetBitrate = options["targetBitrate"] as? Int ?? 3_000_000
|
||||||
|
let maxSize = options["maxSize"] as? Int ?? 1920
|
||||||
|
let jobId = options["jobId"] as? Int ?? 0
|
||||||
|
|
||||||
|
let compressor = VideoCompressor(
|
||||||
|
url: url,
|
||||||
|
targetBitrate: targetBitrate,
|
||||||
|
maxSize: maxSize,
|
||||||
|
jobId: jobId,
|
||||||
|
onProgress: { [weak self] id, progress in
|
||||||
|
self?.sendEvent("onProgress", [
|
||||||
|
"id": id,
|
||||||
|
"progress": progress
|
||||||
|
])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.currentCompressor = compressor
|
||||||
|
|
||||||
|
do {
|
||||||
|
let result = try await compressor.compress()
|
||||||
|
self.currentCompressor = nil
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
self.currentCompressor = nil
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Function("cancel") {
|
||||||
|
self.currentCompressor?.cancel()
|
||||||
|
self.currentCompressor = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
class VideoCompressor {
|
||||||
|
private let url: URL
|
||||||
|
private let targetBitrate: Int
|
||||||
|
private let maxSize: Int
|
||||||
|
private let jobId: Int
|
||||||
|
private let onProgress: (Int, Double) -> Void
|
||||||
|
private var isCancelled = false
|
||||||
|
|
||||||
|
init(
|
||||||
|
url: URL,
|
||||||
|
targetBitrate: Int,
|
||||||
|
maxSize: Int,
|
||||||
|
jobId: Int,
|
||||||
|
onProgress: @escaping (Int, Double) -> Void
|
||||||
|
) {
|
||||||
|
self.url = url
|
||||||
|
self.targetBitrate = targetBitrate
|
||||||
|
self.maxSize = maxSize
|
||||||
|
self.jobId = jobId
|
||||||
|
self.onProgress = onProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
isCancelled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func compress() async throws -> [String: Any] {
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
let duration = try await asset.load(.duration)
|
||||||
|
let totalSeconds = CMTimeGetSeconds(duration)
|
||||||
|
|
||||||
|
guard totalSeconds > 0 else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "ExpoVideoCompress",
|
||||||
|
code: 2,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Invalid video duration"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load video track
|
||||||
|
let videoTracks = try await asset.loadTracks(withMediaType: .video)
|
||||||
|
guard let videoTrack = videoTracks.first else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "ExpoVideoCompress",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "No video track found"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let naturalSize = try await videoTrack.load(.naturalSize)
|
||||||
|
let preferredTransform = try await videoTrack.load(.preferredTransform)
|
||||||
|
|
||||||
|
// Calculate output dimensions
|
||||||
|
let outputSize = calculateOutputSize(
|
||||||
|
naturalSize: naturalSize,
|
||||||
|
transform: preferredTransform,
|
||||||
|
maxSize: maxSize
|
||||||
|
)
|
||||||
|
|
||||||
|
// Load audio tracks
|
||||||
|
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
|
||||||
|
let hasAudio = !audioTracks.isEmpty
|
||||||
|
|
||||||
|
// Audio analysis for passthrough decision
|
||||||
|
var shouldPassthroughAudio = false
|
||||||
|
if hasAudio, let audioTrack = audioTracks.first {
|
||||||
|
shouldPassthroughAudio = try await canPassthroughAudio(audioTrack)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create output file
|
||||||
|
let outputURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(UUID().uuidString)
|
||||||
|
.appendingPathExtension("mp4")
|
||||||
|
|
||||||
|
// Set up reader
|
||||||
|
let reader = try AVAssetReader(asset: asset)
|
||||||
|
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
|
||||||
|
|
||||||
|
// Video reader output - request raw frames
|
||||||
|
let videoReaderSettings: [String: Any] = [
|
||||||
|
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
|
||||||
|
]
|
||||||
|
let videoReaderOutput = AVAssetReaderTrackOutput(
|
||||||
|
track: videoTrack,
|
||||||
|
outputSettings: videoReaderSettings
|
||||||
|
)
|
||||||
|
videoReaderOutput.alwaysCopiesSampleData = false
|
||||||
|
|
||||||
|
guard reader.canAdd(videoReaderOutput) else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "ExpoVideoCompress",
|
||||||
|
code: 3,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Cannot read video track"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
reader.add(videoReaderOutput)
|
||||||
|
|
||||||
|
// Video writer input
|
||||||
|
let videoWriterSettings: [String: Any] = [
|
||||||
|
AVVideoCodecKey: AVVideoCodecType.h264,
|
||||||
|
AVVideoWidthKey: outputSize.width,
|
||||||
|
AVVideoHeightKey: outputSize.height,
|
||||||
|
AVVideoCompressionPropertiesKey: [
|
||||||
|
AVVideoAverageBitRateKey: targetBitrate,
|
||||||
|
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
|
||||||
|
AVVideoMaxKeyFrameIntervalKey: 90, // ~3s at 30fps
|
||||||
|
AVVideoExpectedSourceFrameRateKey: 30
|
||||||
|
] as [String: Any]
|
||||||
|
]
|
||||||
|
let videoWriterInput = AVAssetWriterInput(
|
||||||
|
mediaType: .video,
|
||||||
|
outputSettings: videoWriterSettings
|
||||||
|
)
|
||||||
|
videoWriterInput.expectsMediaDataInRealTime = false
|
||||||
|
|
||||||
|
// Apply transform for rotation
|
||||||
|
videoWriterInput.transform = preferredTransform
|
||||||
|
|
||||||
|
guard writer.canAdd(videoWriterInput) else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "ExpoVideoCompress",
|
||||||
|
code: 4,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Cannot write video track"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
writer.add(videoWriterInput)
|
||||||
|
|
||||||
|
// Audio setup
|
||||||
|
var audioReaderOutput: AVAssetReaderOutput?
|
||||||
|
var audioWriterInput: AVAssetWriterInput?
|
||||||
|
|
||||||
|
if hasAudio, let audioTrack = audioTracks.first {
|
||||||
|
if shouldPassthroughAudio {
|
||||||
|
// Passthrough - copy audio as-is
|
||||||
|
let audioOutput = AVAssetReaderTrackOutput(
|
||||||
|
track: audioTrack,
|
||||||
|
outputSettings: nil
|
||||||
|
)
|
||||||
|
audioOutput.alwaysCopiesSampleData = false
|
||||||
|
if reader.canAdd(audioOutput) {
|
||||||
|
reader.add(audioOutput)
|
||||||
|
audioReaderOutput = audioOutput
|
||||||
|
|
||||||
|
let audioInput = AVAssetWriterInput(
|
||||||
|
mediaType: .audio,
|
||||||
|
outputSettings: nil
|
||||||
|
)
|
||||||
|
audioInput.expectsMediaDataInRealTime = false
|
||||||
|
if writer.canAdd(audioInput) {
|
||||||
|
writer.add(audioInput)
|
||||||
|
audioWriterInput = audioInput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Re-encode audio to AAC 128kbps
|
||||||
|
let audioDecoderSettings: [String: Any] = [
|
||||||
|
AVFormatIDKey: kAudioFormatLinearPCM,
|
||||||
|
AVSampleRateKey: 44100,
|
||||||
|
AVNumberOfChannelsKey: 2,
|
||||||
|
AVLinearPCMBitDepthKey: 16,
|
||||||
|
AVLinearPCMIsFloatKey: false,
|
||||||
|
AVLinearPCMIsBigEndianKey: false,
|
||||||
|
AVLinearPCMIsNonInterleaved: false
|
||||||
|
]
|
||||||
|
let audioOutput = AVAssetReaderTrackOutput(
|
||||||
|
track: audioTrack,
|
||||||
|
outputSettings: audioDecoderSettings
|
||||||
|
)
|
||||||
|
audioOutput.alwaysCopiesSampleData = false
|
||||||
|
if reader.canAdd(audioOutput) {
|
||||||
|
reader.add(audioOutput)
|
||||||
|
audioReaderOutput = audioOutput
|
||||||
|
|
||||||
|
let audioEncoderSettings: [String: Any] = [
|
||||||
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
|
AVSampleRateKey: 44100,
|
||||||
|
AVNumberOfChannelsKey: 2,
|
||||||
|
AVEncoderBitRateKey: 128_000
|
||||||
|
]
|
||||||
|
let audioInput = AVAssetWriterInput(
|
||||||
|
mediaType: .audio,
|
||||||
|
outputSettings: audioEncoderSettings
|
||||||
|
)
|
||||||
|
audioInput.expectsMediaDataInRealTime = false
|
||||||
|
if writer.canAdd(audioInput) {
|
||||||
|
writer.add(audioInput)
|
||||||
|
audioWriterInput = audioInput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start reading/writing
|
||||||
|
reader.startReading()
|
||||||
|
writer.startWriting()
|
||||||
|
writer.startSession(atSourceTime: .zero)
|
||||||
|
|
||||||
|
// Process video and audio concurrently
|
||||||
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||||
|
// Video processing
|
||||||
|
group.addTask {
|
||||||
|
try await self.processTrack(
|
||||||
|
readerOutput: videoReaderOutput,
|
||||||
|
writerInput: videoWriterInput,
|
||||||
|
totalDuration: totalSeconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio processing
|
||||||
|
if let audioOutput = audioReaderOutput, let audioInput = audioWriterInput {
|
||||||
|
group.addTask {
|
||||||
|
try await self.processTrack(
|
||||||
|
readerOutput: audioOutput,
|
||||||
|
writerInput: audioInput,
|
||||||
|
totalDuration: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try await group.waitForAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for cancellation
|
||||||
|
if isCancelled {
|
||||||
|
writer.cancelWriting()
|
||||||
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
|
throw NSError(
|
||||||
|
domain: "ExpoVideoCompress",
|
||||||
|
code: 5,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Compression cancelled"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check reader status
|
||||||
|
if reader.status == .failed {
|
||||||
|
let error = reader.error ?? NSError(
|
||||||
|
domain: "ExpoVideoCompress",
|
||||||
|
code: 6,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Reader failed"]
|
||||||
|
)
|
||||||
|
writer.cancelWriting()
|
||||||
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish writing
|
||||||
|
await writer.finishWriting()
|
||||||
|
|
||||||
|
if writer.status == .failed {
|
||||||
|
let error = writer.error ?? NSError(
|
||||||
|
domain: "ExpoVideoCompress",
|
||||||
|
code: 7,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Writer failed"]
|
||||||
|
)
|
||||||
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get output file attributes
|
||||||
|
let attributes = try FileManager.default.attributesOfItem(atPath: outputURL.path)
|
||||||
|
let fileSize = attributes[.size] as? Int ?? 0
|
||||||
|
|
||||||
|
return [
|
||||||
|
"uri": outputURL.absoluteString,
|
||||||
|
"size": fileSize,
|
||||||
|
"mimeType": "video/mp4",
|
||||||
|
"width": outputSize.width,
|
||||||
|
"height": outputSize.height,
|
||||||
|
"duration": totalSeconds
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processTrack(
|
||||||
|
readerOutput: AVAssetReaderOutput,
|
||||||
|
writerInput: AVAssetWriterInput,
|
||||||
|
totalDuration: Double?
|
||||||
|
) async throws {
|
||||||
|
var lastProgressTime: CFAbsoluteTime = 0
|
||||||
|
|
||||||
|
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||||
|
writerInput.requestMediaDataWhenReady(on: DispatchQueue(label: "com.bluesky.videocompress")) {
|
||||||
|
while writerInput.isReadyForMoreMediaData {
|
||||||
|
if self.isCancelled {
|
||||||
|
writerInput.markAsFinished()
|
||||||
|
continuation.resume()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
|
||||||
|
writerInput.markAsFinished()
|
||||||
|
continuation.resume()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send progress events (throttled to ~10/sec) for video track only
|
||||||
|
if let totalDuration = totalDuration {
|
||||||
|
let now = CFAbsoluteTimeGetCurrent()
|
||||||
|
if now - lastProgressTime >= 0.1 {
|
||||||
|
lastProgressTime = now
|
||||||
|
let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
||||||
|
let progress = min(CMTimeGetSeconds(pts) / totalDuration, 1.0)
|
||||||
|
self.onProgress(self.jobId, progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writerInput.append(sampleBuffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateOutputSize(
|
||||||
|
naturalSize: CGSize,
|
||||||
|
transform: CGAffineTransform,
|
||||||
|
maxSize: Int
|
||||||
|
) -> (width: Int, height: Int) {
|
||||||
|
// Apply transform to get actual display dimensions
|
||||||
|
let isRotated = abs(transform.b) == 1.0 && abs(transform.c) == 1.0
|
||||||
|
let sourceWidth = isRotated ? naturalSize.height : naturalSize.width
|
||||||
|
let sourceHeight = isRotated ? naturalSize.width : naturalSize.height
|
||||||
|
|
||||||
|
let maxDimension = CGFloat(maxSize)
|
||||||
|
|
||||||
|
// If already within bounds, keep original size (rounded to even)
|
||||||
|
if sourceWidth <= maxDimension && sourceHeight <= maxDimension {
|
||||||
|
return (
|
||||||
|
width: roundToEven(Int(sourceWidth)),
|
||||||
|
height: roundToEven(Int(sourceHeight))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale down maintaining aspect ratio
|
||||||
|
let scale: CGFloat
|
||||||
|
if sourceWidth > sourceHeight {
|
||||||
|
scale = maxDimension / sourceWidth
|
||||||
|
} else {
|
||||||
|
scale = maxDimension / sourceHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
width: roundToEven(Int(sourceWidth * scale)),
|
||||||
|
height: roundToEven(Int(sourceHeight * scale))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func roundToEven(_ value: Int) -> Int {
|
||||||
|
return value % 2 == 0 ? value : value - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private func canPassthroughAudio(_ audioTrack: AVAssetTrack) async throws -> Bool {
|
||||||
|
let formatDescriptions = try await audioTrack.load(.formatDescriptions)
|
||||||
|
guard let formatDesc = formatDescriptions.first else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let mediaSubType = CMFormatDescriptionGetMediaSubType(formatDesc)
|
||||||
|
|
||||||
|
// Only passthrough AAC audio
|
||||||
|
guard mediaSubType == kAudioFormatMPEG4AAC else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check bitrate - passthrough if <= 128kbps
|
||||||
|
let estimatedDataRate = try await audioTrack.load(.estimatedDataRate)
|
||||||
|
return estimatedDataRate <= 128_000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
struct VideoProber {
|
||||||
|
static func probe(url: URL) async throws -> [String: Any] {
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
|
||||||
|
let duration = try await asset.load(.duration)
|
||||||
|
let tracks = try await asset.loadTracks(withMediaType: .video)
|
||||||
|
|
||||||
|
guard let videoTrack = tracks.first else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "ExpoVideoCompress",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "No video track found"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let naturalSize = try await videoTrack.load(.naturalSize)
|
||||||
|
let preferredTransform = try await videoTrack.load(.preferredTransform)
|
||||||
|
let estimatedDataRate = try await videoTrack.load(.estimatedDataRate)
|
||||||
|
let nominalFrameRate = try await videoTrack.load(.nominalFrameRate)
|
||||||
|
let formatDescriptions = try await videoTrack.load(.formatDescriptions)
|
||||||
|
|
||||||
|
// Determine codec from format descriptions
|
||||||
|
var codec = "unknown"
|
||||||
|
if let formatDescription = formatDescriptions.first {
|
||||||
|
let mediaSubType = CMFormatDescriptionGetMediaSubType(formatDescription)
|
||||||
|
codec = fourCCToString(mediaSubType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect rotation from transform
|
||||||
|
let rotation = rotationFromTransform(preferredTransform)
|
||||||
|
|
||||||
|
// Apply rotation to get display size
|
||||||
|
let isRotated = rotation == 90 || rotation == 270
|
||||||
|
let width = isRotated ? Int(naturalSize.height) : Int(naturalSize.width)
|
||||||
|
let height = isRotated ? Int(naturalSize.width) : Int(naturalSize.height)
|
||||||
|
|
||||||
|
// Check for audio track
|
||||||
|
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
|
||||||
|
let hasAudio = !audioTracks.isEmpty
|
||||||
|
|
||||||
|
// Get file size
|
||||||
|
let fileSize: Int
|
||||||
|
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||||
|
let size = attributes[.size] as? Int {
|
||||||
|
fileSize = size
|
||||||
|
} else {
|
||||||
|
fileSize = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bitrate: use estimated data rate, or calculate from file size
|
||||||
|
let durationSeconds = CMTimeGetSeconds(duration)
|
||||||
|
var bitrate = Int(estimatedDataRate)
|
||||||
|
if bitrate == 0 && durationSeconds > 0 {
|
||||||
|
bitrate = Int(Double(fileSize * 8) / durationSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"duration": durationSeconds,
|
||||||
|
"bitrate": bitrate,
|
||||||
|
"fileSize": fileSize,
|
||||||
|
"codec": codec,
|
||||||
|
"hasAudio": hasAudio,
|
||||||
|
"frameRate": nominalFrameRate,
|
||||||
|
"rotation": rotation
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func rotationFromTransform(_ transform: CGAffineTransform) -> Int {
|
||||||
|
let angle = atan2(transform.b, transform.a)
|
||||||
|
let degrees = Int(round(angle * 180.0 / .pi))
|
||||||
|
// Normalize to 0, 90, 180, 270
|
||||||
|
let normalized = ((degrees % 360) + 360) % 360
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fourCCToString(_ code: FourCharCode) -> String {
|
||||||
|
let chars: [Character] = [
|
||||||
|
Character(UnicodeScalar((code >> 24) & 0xFF)!),
|
||||||
|
Character(UnicodeScalar((code >> 16) & 0xFF)!),
|
||||||
|
Character(UnicodeScalar((code >> 8) & 0xFF)!),
|
||||||
|
Character(UnicodeScalar(code & 0xFF)!)
|
||||||
|
]
|
||||||
|
return String(chars).trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {type EventSubscription, requireNativeModule} from 'expo-modules-core'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CompressResult,
|
||||||
|
type NativeCompressOptions,
|
||||||
|
type VideoMetadata,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
type ProgressEvent = {id: number; progress: number}
|
||||||
|
|
||||||
|
interface ExpoVideoCompressModule {
|
||||||
|
probe(uri: string): Promise<VideoMetadata>
|
||||||
|
compress(uri: string, options: NativeCompressOptions): Promise<CompressResult>
|
||||||
|
cancel(): void
|
||||||
|
addListener(
|
||||||
|
eventName: 'onProgress',
|
||||||
|
listener: (event: ProgressEvent) => void,
|
||||||
|
): EventSubscription
|
||||||
|
}
|
||||||
|
|
||||||
|
export default requireNativeModule<ExpoVideoCompressModule>('ExpoVideoCompress')
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export type VideoMetadata = {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
duration: number // seconds
|
||||||
|
bitrate: number // bps
|
||||||
|
fileSize: number // bytes
|
||||||
|
codec: string
|
||||||
|
hasAudio: boolean
|
||||||
|
frameRate: number
|
||||||
|
rotation: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompressOptions = {
|
||||||
|
targetBitrate: number // bps (e.g. 3_000_000)
|
||||||
|
maxSize: number // max dimension in pixels (e.g. 1920)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompressResult = {
|
||||||
|
uri: string
|
||||||
|
size: number
|
||||||
|
mimeType: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NativeCompressOptions = CompressOptions & {jobId: number}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {getVideoMetaData, Video} from 'react-native-compressor'
|
||||||
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||||
|
|
||||||
|
import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants'
|
||||||
|
import {type CompressedVideo} from './types'
|
||||||
|
import {extToMime} from './util'
|
||||||
|
|
||||||
|
const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb
|
||||||
|
|
||||||
|
export async function compressVideo(
|
||||||
|
file: ImagePickerAsset,
|
||||||
|
opts?: {
|
||||||
|
signal?: AbortSignal
|
||||||
|
onProgress?: (progress: number) => void
|
||||||
|
},
|
||||||
|
): Promise<CompressedVideo> {
|
||||||
|
const {onProgress, signal} = opts || {}
|
||||||
|
|
||||||
|
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
|
||||||
|
file.mimeType as SupportedMimeTypes,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (file.mimeType === 'image/gif') {
|
||||||
|
// let's hope they're small enough that they don't need compression!
|
||||||
|
// this compression library doesn't support gifs
|
||||||
|
// worst case - server rejects them. I think that's fine -sfn
|
||||||
|
return {uri: file.uri, size: file.fileSize ?? -1, mimeType: 'image/gif'}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minimumFileSizeForCompress = isAcceptableFormat
|
||||||
|
? MIN_SIZE_FOR_COMPRESSION
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const compressed = await Video.compress(
|
||||||
|
file.uri,
|
||||||
|
{
|
||||||
|
compressionMethod: 'manual',
|
||||||
|
bitrate: 3_000_000, // 3mbps
|
||||||
|
maxSize: 1920,
|
||||||
|
// WARNING: this ONE SPECIFIC ARG is in MB -sfn
|
||||||
|
minimumFileSizeForCompress,
|
||||||
|
getCancellationId: id => {
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener('abort', () => {
|
||||||
|
Video.cancelCompression(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onProgress,
|
||||||
|
)
|
||||||
|
|
||||||
|
const info = await getVideoMetaData(compressed)
|
||||||
|
|
||||||
|
return {uri: compressed, size: info.size, mimeType: extToMime(info.extension)}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||||
|
|
||||||
|
import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants'
|
||||||
|
import {compress, probe} from '../../../../modules/expo-bluesky-video-compress'
|
||||||
|
import {type CompressedVideo} from './types'
|
||||||
|
|
||||||
|
// Skip compression if bitrate is at/below this threshold (bps)
|
||||||
|
const PASSTHROUGH_BITRATE = 3_300_000
|
||||||
|
// Max dimension that doesn't need downscaling
|
||||||
|
const PASSTHROUGH_MAX_DIMENSION = 1920
|
||||||
|
// Max file size the server accepts (bytes)
|
||||||
|
const MAX_UPLOAD_SIZE = 100 * 1000 * 1000 // 100MB
|
||||||
|
|
||||||
|
export async function compressVideo(
|
||||||
|
file: ImagePickerAsset,
|
||||||
|
opts?: {
|
||||||
|
signal?: AbortSignal
|
||||||
|
onProgress?: (progress: number) => void
|
||||||
|
},
|
||||||
|
): Promise<CompressedVideo> {
|
||||||
|
if (file.mimeType === 'image/gif') {
|
||||||
|
return {uri: file.uri, size: file.fileSize ?? -1, mimeType: 'image/gif'}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
|
||||||
|
file.mimeType as SupportedMimeTypes,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Probe the video to make a smart compression decision
|
||||||
|
const metadata = await probe(file.uri)
|
||||||
|
|
||||||
|
const needsCompression = shouldCompress(metadata, isAcceptableFormat)
|
||||||
|
|
||||||
|
if (!needsCompression) {
|
||||||
|
return {
|
||||||
|
uri: file.uri,
|
||||||
|
size: metadata.fileSize,
|
||||||
|
mimeType: file.mimeType ?? 'video/mp4',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await compress(
|
||||||
|
file.uri,
|
||||||
|
{
|
||||||
|
targetBitrate: 3_000_000,
|
||||||
|
maxSize: 1920,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onProgress: opts?.onProgress,
|
||||||
|
signal: opts?.signal,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
uri: result.uri,
|
||||||
|
size: result.size,
|
||||||
|
mimeType: result.mimeType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldCompress(
|
||||||
|
metadata: {bitrate: number; width: number; height: number; fileSize: number},
|
||||||
|
isAcceptableFormat: boolean,
|
||||||
|
): boolean {
|
||||||
|
// Always compress unacceptable formats (e.g. MOV → MP4)
|
||||||
|
if (!isAcceptableFormat) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must compress if over upload limit
|
||||||
|
if (metadata.fileSize > MAX_UPLOAD_SIZE) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if already low bitrate, small resolution, and under upload limit
|
||||||
|
const maxDimension = Math.max(metadata.width, metadata.height)
|
||||||
|
if (
|
||||||
|
metadata.bitrate <= PASSTHROUGH_BITRATE &&
|
||||||
|
maxDimension <= PASSTHROUGH_MAX_DIMENSION &&
|
||||||
|
metadata.fileSize <= MAX_UPLOAD_SIZE
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import {getVideoMetaData, Video} from 'react-native-compressor'
|
|
||||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||||
|
|
||||||
import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants'
|
|
||||||
import {type CompressedVideo} from './types'
|
import {type CompressedVideo} from './types'
|
||||||
import {extToMime} from './util'
|
|
||||||
|
|
||||||
const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb
|
// Toggle for A/B comparison. Set to true to use the new Expo module.
|
||||||
|
const USE_NEW_COMPRESSOR = false
|
||||||
|
|
||||||
export async function compressVideo(
|
export async function compressVideo(
|
||||||
file: ImagePickerAsset,
|
file: ImagePickerAsset,
|
||||||
@@ -14,43 +12,13 @@ export async function compressVideo(
|
|||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
},
|
},
|
||||||
): Promise<CompressedVideo> {
|
): Promise<CompressedVideo> {
|
||||||
const {onProgress, signal} = opts || {}
|
if (USE_NEW_COMPRESSOR) {
|
||||||
|
const {compressVideo: compressVideoNew} = await import('./compress.new')
|
||||||
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
|
return compressVideoNew(file, opts)
|
||||||
file.mimeType as SupportedMimeTypes,
|
} else {
|
||||||
|
const {compressVideo: compressVideoLegacy} = await import(
|
||||||
|
'./compress.legacy'
|
||||||
)
|
)
|
||||||
|
return compressVideoLegacy(file, opts)
|
||||||
if (file.mimeType === 'image/gif') {
|
|
||||||
// let's hope they're small enough that they don't need compression!
|
|
||||||
// this compression library doesn't support gifs
|
|
||||||
// worst case - server rejects them. I think that's fine -sfn
|
|
||||||
return {uri: file.uri, size: file.fileSize ?? -1, mimeType: 'image/gif'}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const minimumFileSizeForCompress = isAcceptableFormat
|
|
||||||
? MIN_SIZE_FOR_COMPRESSION
|
|
||||||
: 0
|
|
||||||
|
|
||||||
const compressed = await Video.compress(
|
|
||||||
file.uri,
|
|
||||||
{
|
|
||||||
compressionMethod: 'manual',
|
|
||||||
bitrate: 3_000_000, // 3mbps
|
|
||||||
maxSize: 1920,
|
|
||||||
// WARNING: this ONE SPECIFIC ARG is in MB -sfn
|
|
||||||
minimumFileSizeForCompress,
|
|
||||||
getCancellationId: id => {
|
|
||||||
if (signal) {
|
|
||||||
signal.addEventListener('abort', () => {
|
|
||||||
Video.cancelCompression(id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onProgress,
|
|
||||||
)
|
|
||||||
|
|
||||||
const info = await getVideoMetaData(compressed)
|
|
||||||
|
|
||||||
return {uri: compressed, size: info.size, mimeType: extToMime(info.extension)}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user