claude's first attempt

This commit is contained in:
Samuel Newman
2026-03-11 13:34:14 +02:00
parent 1c2fd5b280
commit c1d92e84ac
16 changed files with 1655 additions and 42 deletions
@@ -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 {
}
@@ -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
)
}
}
@@ -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
}
}
}
@@ -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
}
}
@@ -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}
+56
View File
@@ -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)}
}
+86
View File
@@ -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
}
+10 -42
View File
@@ -1,11 +1,9 @@
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
// Toggle for A/B comparison. Set to true to use the new Expo module.
const USE_NEW_COMPRESSOR = false
export async function compressVideo(
file: ImagePickerAsset,
@@ -14,43 +12,13 @@ export async function compressVideo(
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'}
if (USE_NEW_COMPRESSOR) {
const {compressVideo: compressVideoNew} = await import('./compress.new')
return compressVideoNew(file, opts)
} else {
const {compressVideo: compressVideoLegacy} = await import(
'./compress.legacy'
)
return compressVideoLegacy(file, opts)
}
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)}
}