diff --git a/modules/expo-bluesky-video-compress/android/build.gradle b/modules/expo-bluesky-video-compress/android/build.gradle new file mode 100644 index 0000000000..cbe3444463 --- /dev/null +++ b/modules/expo-bluesky-video-compress/android/build.gradle @@ -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 { +} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideo/CodecSelector.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideo/CodecSelector.kt new file mode 100644 index 0000000000..3b6dc34e8d --- /dev/null +++ b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideo/CodecSelector.kt @@ -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() + val softwareEncoders = mutableListOf() + + 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 + ) + } +} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideo/ExpoVideoCompressModule.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideo/ExpoVideoCompressModule.kt new file mode 100644 index 0000000000..d9118941a7 --- /dev/null +++ b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideo/ExpoVideoCompressModule.kt @@ -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 -> + 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 + } + } +} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideo/VideoCompressor.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideo/VideoCompressor.kt new file mode 100644 index 0000000000..698fac9f07 --- /dev/null +++ b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideo/VideoCompressor.kt @@ -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 { + 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 { + 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 { + // 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 + } +} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideo/VideoProber.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideo/VideoProber.kt new file mode 100644 index 0000000000..1a176e9540 --- /dev/null +++ b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideo/VideoProber.kt @@ -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 { + 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 + } + } +} diff --git a/modules/expo-bluesky-video-compress/expo-module.config.json b/modules/expo-bluesky-video-compress/expo-module.config.json new file mode 100644 index 0000000000..8df0f76f4f --- /dev/null +++ b/modules/expo-bluesky-video-compress/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["ios", "android"], + "ios": { + "modules": ["ExpoVideoCompressModule"] + }, + "android": { + "modules": ["expo.modules.blueskyvideo.ExpoVideoCompressModule"] + } +} diff --git a/modules/expo-bluesky-video-compress/index.ts b/modules/expo-bluesky-video-compress/index.ts new file mode 100644 index 0000000000..11f771f366 --- /dev/null +++ b/modules/expo-bluesky-video-compress/index.ts @@ -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 { + return NativeModule.probe(uri) +} + +export function compress( + uri: string, + options: CompressOptions, + callbacks?: { + onProgress?: (progress: number) => void + signal?: AbortSignal + }, +): Promise { + const jobId = ++jobIdCounter + let subscription: EventSubscription | undefined + + if (callbacks?.signal?.aborted) { + return Promise.reject(new DOMException('Aborted', 'AbortError')) + } + + return new Promise((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) + }) + }) +} diff --git a/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompress.podspec b/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompress.podspec new file mode 100644 index 0000000000..d2029e30c9 --- /dev/null +++ b/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompress.podspec @@ -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 diff --git a/modules/expo-bluesky-video-compress/ios/ExpoVideoCompressModule.swift b/modules/expo-bluesky-video-compress/ios/ExpoVideoCompressModule.swift new file mode 100644 index 0000000000..3e43c7c0b8 --- /dev/null +++ b/modules/expo-bluesky-video-compress/ios/ExpoVideoCompressModule.swift @@ -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 + } + } +} diff --git a/modules/expo-bluesky-video-compress/ios/VideoCompressor.swift b/modules/expo-bluesky-video-compress/ios/VideoCompressor.swift new file mode 100644 index 0000000000..985debd594 --- /dev/null +++ b/modules/expo-bluesky-video-compress/ios/VideoCompressor.swift @@ -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) 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 + } +} diff --git a/modules/expo-bluesky-video-compress/ios/VideoProber.swift b/modules/expo-bluesky-video-compress/ios/VideoProber.swift new file mode 100644 index 0000000000..fd9d1d62e9 --- /dev/null +++ b/modules/expo-bluesky-video-compress/ios/VideoProber.swift @@ -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) + } +} diff --git a/modules/expo-bluesky-video-compress/src/ExpoVideoCompressModule.ts b/modules/expo-bluesky-video-compress/src/ExpoVideoCompressModule.ts new file mode 100644 index 0000000000..674c9942b2 --- /dev/null +++ b/modules/expo-bluesky-video-compress/src/ExpoVideoCompressModule.ts @@ -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 + compress(uri: string, options: NativeCompressOptions): Promise + cancel(): void + addListener( + eventName: 'onProgress', + listener: (event: ProgressEvent) => void, + ): EventSubscription +} + +export default requireNativeModule('ExpoVideoCompress') diff --git a/modules/expo-bluesky-video-compress/src/types.ts b/modules/expo-bluesky-video-compress/src/types.ts new file mode 100644 index 0000000000..93baea1fc7 --- /dev/null +++ b/modules/expo-bluesky-video-compress/src/types.ts @@ -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} diff --git a/src/lib/media/video/compress.legacy.ts b/src/lib/media/video/compress.legacy.ts new file mode 100644 index 0000000000..1d00bfcea1 --- /dev/null +++ b/src/lib/media/video/compress.legacy.ts @@ -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 { + 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)} +} diff --git a/src/lib/media/video/compress.new.ts b/src/lib/media/video/compress.new.ts new file mode 100644 index 0000000000..78d610418a --- /dev/null +++ b/src/lib/media/video/compress.new.ts @@ -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 { + 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 +} diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts index 1d00bfcea1..32c9867ed4 100644 --- a/src/lib/media/video/compress.ts +++ b/src/lib/media/video/compress.ts @@ -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 { - 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)} }