diff --git a/lib/media/video-utils.js b/lib/media/video-utils.js
--- a/lib/media/video-utils.js
+++ b/lib/media/video-utils.js
@@ -1,7 +1,4 @@
 // @flow
-
-import invariant from 'invariant';
-
 import { replaceExtension, sanitizeFilename } from './file-utils.js';
 import { maxDimensions } from './media-utils.js';
 import type { Dimensions, MediaMissionFailure } from '../types/media-types.js';
@@ -20,18 +17,20 @@
   +inputDuration: number,
   +inputDimensions: Dimensions,
   +outputDirectory: string,
-  +outputCodec: string,
   +clientConnectionInfo?: {
     +hasWiFi: boolean,
     +speed: number, // in kilobytes per second
   },
   +clientTranscodeSpeed?: number, // in input video seconds per second
 };
+
 export type ProcessPlan = {
   +action: 'process',
+  +inputPath: string,
   +outputPath: string,
   +thumbnailPath: string,
-  +ffmpegCommand: string,
+  +width: number,
+  +height: number,
 };
 type Plan =
   | { +action: 'none', +thumbnailPath: string }
@@ -47,7 +46,6 @@
     inputDuration,
     inputDimensions,
     outputDirectory,
-    outputCodec,
     clientConnectionInfo,
     clientTranscodeSpeed,
   } = input;
@@ -97,44 +95,31 @@
   );
   const outputPath = `${outputDirectory}${outputFilename}`;
 
-  let hardwareAcceleration, quality, speed, scale, pixelFormat;
-  if (outputCodec === 'h264_mediacodec') {
-    hardwareAcceleration = 'mediacodec';
-    quality = '';
-    speed = '';
-    scale = `-vf scale=${maxWidth}:${maxHeight}:force_original_aspect_ratio=decrease`;
-    pixelFormat = '';
-  } else if (outputCodec === 'h264_videotoolbox') {
-    hardwareAcceleration = 'videotoolbox';
-    quality = '-profile:v baseline';
-    speed = '-realtime 1';
-    const { width, height } = inputDimensions;
-    scale = '';
-    const exceedsDimensions = width > maxWidth || height > maxHeight;
-    if (exceedsDimensions && width / height > maxWidth / maxHeight) {
-      scale = `-vf scale=${maxWidth}:-1`;
-    } else if (exceedsDimensions) {
-      scale = `-vf scale=-1:${maxHeight}`;
+  const { width, height } = inputDimensions;
+  let targetWidth = -1,
+    targetHeight = -1;
+  const exceedsDimensions = width > maxWidth || height > maxHeight;
+  if (exceedsDimensions) {
+    if (width / height > maxWidth / maxHeight) {
+      targetWidth = maxWidth;
+      targetHeight = (maxWidth * height) / width;
+    } else {
+      targetHeight = maxHeight;
+      targetWidth = (maxHeight * width) / height;
     }
-    pixelFormat = '-pix_fmt yuv420p';
   } else {
-    invariant(false, `unrecognized outputCodec ${outputCodec}`);
+    targetWidth = width;
+    targetHeight = height;
   }
 
-  const ffmpegCommand =
-    `-hwaccel ${hardwareAcceleration} ` +
-    `-i ${inputPath} ` +
-    `-c:a copy -c:v ${outputCodec} ` +
-    `${quality} ` +
-    '-fps_mode cfr -r 30 ' +
-    `${scale} ` +
-    `${speed} ` +
-    '-movflags +faststart ' +
-    `${pixelFormat} ` +
-    '-v quiet ' +
-    outputPath;
-
-  return { action: 'process', thumbnailPath, outputPath, ffmpegCommand };
+  return {
+    action: 'process',
+    thumbnailPath,
+    inputPath,
+    outputPath,
+    width: targetWidth,
+    height: targetHeight,
+  };
 }
 
 const videoDurationLimit = 3; // in minutes
diff --git a/lib/types/media-types.js b/lib/types/media-types.js
--- a/lib/types/media-types.js
+++ b/lib/types/media-types.js
@@ -104,10 +104,6 @@
   +time: number,
   // total result file size in bytes so far
   +size: number,
-  +videoQuality: number,
-  +videoFrameNumber: number,
-  +videoFps: number,
-  +bitrate: number,
 };
 
 export type TranscodeVideoMediaMissionStep = {
@@ -115,7 +111,6 @@
   +success: boolean,
   +exceptionMessage: ?string,
   +time: number, // ms
-  +returnCode: ?number,
   +newPath: ?string,
   +stats: ?FFmpegStatistics,
 };
diff --git a/native/media/ffmpeg.js b/native/media/ffmpeg.js
--- a/native/media/ffmpeg.js
+++ b/native/media/ffmpeg.js
@@ -1,13 +1,12 @@
 // @flow
-
-import { FFmpegKit, FFmpegKitConfig } from 'ffmpeg-kit-react-native';
-
 import type { FFmpegStatistics, VideoInfo } from 'lib/types/media-types.js';
 
+import type { TranscodeOptions } from '../utils/media-module.js';
 import {
   getVideoInfo,
   hasMultipleFrames,
   generateThumbnail,
+  transcodeVideo,
 } from '../utils/media-module.js';
 
 const maxSimultaneousCalls = {
@@ -80,32 +79,23 @@
   }
 
   transcodeVideo(
-    ffmpegCommand: string,
-    inputVideoDuration: number,
+    inputPath: string,
+    outputPath: string,
+    transcodeOptions: TranscodeOptions,
     onTranscodingProgress?: (percent: number) => void,
-  ): Promise<{ rc: number, lastStats: ?FFmpegStatistics }> {
-    const duration = inputVideoDuration > 0 ? inputVideoDuration : 0.001;
+  ): Promise<FFmpegStatistics> {
     const wrappedCommand = async () => {
-      let lastStats;
-      if (onTranscodingProgress) {
-        FFmpegKitConfig.enableStatisticsCallback(statisticsObject => {
-          const time = statisticsObject.getTime();
-          onTranscodingProgress(time / 1000 / duration);
-          lastStats = {
-            speed: statisticsObject.getSpeed(),
-            time,
-            size: statisticsObject.getSize(),
-            videoQuality: statisticsObject.getVideoQuality(),
-            videoFrameNumber: statisticsObject.getVideoFrameNumber(),
-            videoFps: statisticsObject.getVideoFps(),
-            bitrate: statisticsObject.getBitrate(),
-          };
-        });
-      }
-      const session = await FFmpegKit.execute(ffmpegCommand);
-      const returnCode = await session.getReturnCode();
-      const rc = returnCode.getValue();
-      return { rc, lastStats };
+      const stats = await transcodeVideo(
+        inputPath,
+        outputPath,
+        transcodeOptions,
+        onTranscodingProgress,
+      );
+      return {
+        speed: stats.speed,
+        time: stats.duration,
+        size: stats.size,
+      };
     };
     return this.queueCommand('process', wrappedCommand);
   }
diff --git a/native/media/video-utils.js b/native/media/video-utils.js
--- a/native/media/video-utils.js
+++ b/native/media/video-utils.js
@@ -1,7 +1,6 @@
 // @flow
 
 import invariant from 'invariant';
-import { Platform } from 'react-native';
 import filesystem from 'react-native-fs';
 
 import { mediaConfig, pathFromURI } from 'lib/media/file-utils.js';
@@ -32,6 +31,8 @@
 const validCodecs = ['avc', 'avc1', 'h264'];
 const validFormats = ['mp4'];
 
+const transcodeBitrate = 4000; // kb/s
+
 type ProcessVideoInfo = {
   +uri: string,
   +mime: string,
@@ -82,11 +83,6 @@
     inputDuration: duration,
     inputDimensions: input.dimensions,
     outputDirectory: temporaryDirectoryPath,
-    outputCodec: Platform.select({
-      ios: 'h264_videotoolbox',
-      android: 'h264_mediacodec',
-      default: 'h264',
-    }),
     clientConnectionInfo: {
       hasWiFi: input.hasWiFi,
       speed: input.hasWiFi ? uploadSpeeds.wifi : uploadSpeeds.cellular,
@@ -216,33 +212,28 @@
   onProgressCallback?: number => void,
 ): Promise<TranscodeVideoMediaMissionStep> {
   const transcodeStart = Date.now();
-  let returnCode,
-    newPath,
-    stats,
-    success = false,
-    exceptionMessage;
+  let newPath, stats, exceptionMessage;
   try {
-    const { rc, lastStats } = await ffmpeg.transcodeVideo(
-      plan.ffmpegCommand,
-      duration,
+    stats = await ffmpeg.transcodeVideo(
+      plan.inputPath,
+      plan.outputPath,
+      {
+        width: plan.width,
+        height: plan.height,
+        bitrate: transcodeBitrate,
+      },
       onProgressCallback,
     );
-    success = rc === 0;
-    if (success) {
-      returnCode = rc;
-      newPath = plan.outputPath;
-      stats = lastStats;
-    }
+    newPath = plan.outputPath;
   } catch (e) {
     exceptionMessage = getMessageForException(e);
   }
 
   return {
     step: 'video_ffmpeg_transcode',
-    success,
+    success: !exceptionMessage,
     exceptionMessage,
     time: Date.now() - transcodeStart,
-    returnCode,
     newPath,
     stats,
   };
diff --git a/native/utils/media-module.js b/native/utils/media-module.js
--- a/native/utils/media-module.js
+++ b/native/utils/media-module.js
@@ -1,6 +1,10 @@
 // @flow
 
-import { requireNativeModule } from 'expo-modules-core';
+import {
+  requireNativeModule,
+  NativeModulesProxy,
+  EventEmitter,
+} from 'expo-modules-core';
 
 type VideoInfo = {
   +duration: number, // seconds
@@ -10,12 +14,40 @@
   +format: string,
 };
 
+export type H264Profile = 'baseline' | 'main' | 'high';
+
+export type TranscodeOptions = {
+  +width: number,
+  +height: number,
+  +bitrate?: number,
+  +profile?: H264Profile,
+};
+
+type ProgressCallback = (progress: number) => void;
+
+type TranscodeProgressEvent = {
+  +progress: number,
+};
+
+type TranscodeStats = {
+  +size: number,
+  +duration: number,
+  +speed: number,
+};
+
 const MediaModule: {
   +getVideoInfo: (path: string) => Promise<VideoInfo>,
   +hasMultipleFrames: (path: string) => Promise<boolean>,
   +generateThumbnail: (inputPath: string, outputPath: string) => Promise<void>,
+  +transcodeVideo: (
+    inputPath: string,
+    outputPath: string,
+    options: TranscodeOptions,
+  ) => Promise<TranscodeStats>,
 } = requireNativeModule('MediaModule');
 
+const emitter = new EventEmitter(MediaModule ?? NativeModulesProxy.MediaModule);
+
 export function getVideoInfo(path: string): Promise<VideoInfo> {
   return MediaModule.getVideoInfo(path);
 }
@@ -30,3 +62,20 @@
 ): Promise<void> {
   return MediaModule.generateThumbnail(inputPath, outputPath);
 }
+
+export async function transcodeVideo(
+  inputPath: string,
+  outputPath: string,
+  options: TranscodeOptions,
+  progressCallback?: ProgressCallback,
+): Promise<TranscodeStats> {
+  const listener = (event: TranscodeProgressEvent) => {
+    progressCallback?.(event.progress);
+  };
+  const subscription = emitter.addListener('onTranscodeProgress', listener);
+  try {
+    return await MediaModule.transcodeVideo(inputPath, outputPath, options);
+  } finally {
+    subscription.remove();
+  }
+}