diff --git a/native/expo-modules/comm-expo-package/android/build.gradle b/native/expo-modules/comm-expo-package/android/build.gradle --- a/native/expo-modules/comm-expo-package/android/build.gradle +++ b/native/expo-modules/comm-expo-package/android/build.gradle @@ -93,4 +93,8 @@ // dependencies of AndroidLifecycleModule implementation "androidx.lifecycle:lifecycle-runtime:2.5.1" implementation "androidx.lifecycle:lifecycle-process:2.5.1" + + // dependencies of MediaModule + // keep in sync with Expo's media3 dependencies + implementation 'androidx.media3:media3-transformer:1.4.0' } diff --git a/native/expo-modules/comm-expo-package/android/src/main/java/app/comm/android/media/MediaModule.kt b/native/expo-modules/comm-expo-package/android/src/main/java/app/comm/android/media/MediaModule.kt --- a/native/expo-modules/comm-expo-package/android/src/main/java/app/comm/android/media/MediaModule.kt +++ b/native/expo-modules/comm-expo-package/android/src/main/java/app/comm/android/media/MediaModule.kt @@ -5,16 +5,37 @@ import android.graphics.ImageDecoder import android.graphics.Movie import android.graphics.drawable.AnimatedImageDrawable +import android.media.MediaCodecInfo.CodecProfileLevel import android.media.MediaExtractor import android.media.MediaFormat import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build +import android.os.Handler +import android.os.Looper +import androidx.annotation.OptIn +import androidx.media3.common.Format +import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.effect.Presentation +import androidx.media3.transformer.Composition +import androidx.media3.transformer.DefaultEncoderFactory +import androidx.media3.transformer.EditedMediaItem +import androidx.media3.transformer.Effects +import androidx.media3.transformer.ExportException +import androidx.media3.transformer.ExportResult +import androidx.media3.transformer.ProgressHolder +import androidx.media3.transformer.Transformer +import androidx.media3.transformer.VideoEncoderSettings +import expo.modules.kotlin.Promise import expo.modules.kotlin.exception.CodedException import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.records.Field import expo.modules.kotlin.records.Record +import expo.modules.kotlin.types.Enumerable +import java.io.File import java.io.FileOutputStream class VideoInfo : Record { @@ -34,6 +55,39 @@ var format: String = "N/A" } +enum class H264Profile(val value: String) : Enumerable { + baseline("baseline"), + main("main"), + high("high"), +} + + +class TranscodeOptions : Record { + @Field + var width: Double = -1.0 + + @Field + var height: Double = -1.0 + + @Field + var bitrate: Int = -1 + + @Field + var profile: H264Profile = H264Profile.high +} + +class TranscodeStats : Record { + @Field + var size: Long = 0 + + @Field + var duration: Int = 0 + + @Field + var speed: Double = 0.0 +} + +const val TRANSCODE_PROGRESS_EVENT_NAME = "onTranscodeProgress" class MediaModule : Module() { override fun definition() = ModuleDefinition { @@ -42,6 +96,9 @@ AsyncFunction("getVideoInfo", this@MediaModule::getVideoInfo) AsyncFunction("hasMultipleFrames", this@MediaModule::hasMultipleFrames) AsyncFunction("generateThumbnail", this@MediaModule::generateThumbnail) + AsyncFunction("transcodeVideo", this@MediaModule::transcodeVideo) + + Events(TRANSCODE_PROGRESS_EVENT_NAME) } @@ -83,7 +140,6 @@ return videoInfo } - private fun hasMultipleFrames(path: String): Boolean { val uri = Uri.parse(path) try { @@ -129,6 +185,118 @@ } } + // media3 library has unstable api + @OptIn(UnstableApi::class) + private fun transcodeVideo( + inputPath: String, + outputPath: String, + options: TranscodeOptions, + promise: Promise + ) { + val inputUri = Uri.parse(inputPath) + if(inputUri == null) { + promise.reject(TranscodingFailed(Exception("Invalid input url: $inputPath"))) + return + } + val outputUri = Uri.parse(outputPath).path + if(outputUri == null) { + promise.reject(TranscodingFailed(Exception("Invalid output url: $outputPath"))) + return + } + + val mediaItem = MediaItem.Builder() + .setUri(inputUri) + .build() + + val effects = Effects( + listOf(), listOf( + Presentation.createForWidthAndHeight( + options.width.toInt(), + options.height.toInt(), + Presentation.LAYOUT_SCALE_TO_FIT + ) + ) + ) + + val editedMediaItem = EditedMediaItem.Builder(mediaItem) + .setEffects(effects) + .build() + + val profile = when (options.profile) { + H264Profile.baseline -> CodecProfileLevel.AVCProfileBaseline + H264Profile.main -> CodecProfileLevel.AVCProfileMain + H264Profile.high -> CodecProfileLevel.AVCProfileHigh + } + + val videoEncoderFactory = DefaultEncoderFactory.Builder(context) + .setRequestedVideoEncoderSettings( + VideoEncoderSettings.DEFAULT.buildUpon() + .setBitrate(if (options.bitrate == -1) Format.NO_VALUE else options.bitrate * 1000) + .setEncodingProfileLevel(profile, Format.NO_VALUE) + .build() + ) + .build() + + + val newFile = File(outputUri) + + if (!newFile.exists()) { + val created = newFile.createNewFile() + if (!created) { + promise.reject(FailedToCreateFile(outputPath)) + return + } + } + var transformer: Transformer? = null + val handler = Handler(Looper.getMainLooper()) + val progressHolder = ProgressHolder() + val runnable = object : Runnable { + override fun run() { + transformer?.getProgress(progressHolder) + sendEvent( + TRANSCODE_PROGRESS_EVENT_NAME, + mapOf("progress" to progressHolder.progress / 100.0) + ) + handler.postDelayed(this, 200) + } + } + + handler.post { + val startTime = System.currentTimeMillis() + transformer = Transformer.Builder(context) + .setVideoMimeType(MimeTypes.VIDEO_H264) + .setAudioMimeType(MimeTypes.AUDIO_AAC) + .setEncoderFactory(videoEncoderFactory) + .addListener(object : Transformer.Listener { + override fun onCompleted(composition: Composition, exportResult: ExportResult) { + handler.removeCallbacks(runnable) + sendEvent( + TRANSCODE_PROGRESS_EVENT_NAME, + mapOf("progress" to 1) + ) + val stats = TranscodeStats() + stats.duration = (exportResult.durationMs / 1000).toInt() + stats.size = exportResult.fileSizeBytes + val endTime = System.currentTimeMillis() + stats.speed = (exportResult.durationMs).toDouble() / (endTime - startTime) + promise.resolve(stats) + } + + override fun onError( + composition: Composition, + exportResult: ExportResult, + exportException: ExportException + ) { + handler.removeCallbacks(runnable) + promise.reject(TranscodingFailed(exportException)) + } + }) + .build() + transformer?.start(editedMediaItem, outputUri) + } + handler.post(runnable) + } + private val context: Context get() = requireNotNull(this.appContext.reactContext) { "React Application Context is null" @@ -154,4 +322,9 @@ private class SaveThumbnailException(uri: String, cause: Throwable) : CodedException("Could not save thumbnail to $uri", cause) +private class FailedToCreateFile(uri: String) : + CodedException("Failed to create file $uri") + +private class TranscodingFailed(cause: Throwable) : + CodedException("Transcoding failed: ", cause) // endregion