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