Exporting a Lottie Animation to Video Using Media3

Summary

Exporting a Lottie animation to MP4 — with audio — unlocks a ton for creative and media-heavy apps. From story editors to motion-graphics tools, turning Lottie scenes into shareable video clips lets users post anywhere. It’s a common pattern in successful template-driven apps—think Unfold, InStories, and Storybeat—where users start from animated templates and customize them with photos, text, or video.

This post walks you through a clean and performant pipeline that renders Lottie to video using the Media3 Transformer API and OpenGL — and adds audio support along the way.

What We’ll Build

We’ll implement a function:

suspend fun recordLottieToVideo(
  context: Context,
  lottieFrameFactory: LottieFrameFactory,
  audioUri: String,
  outputFilePath: String,
  onProgress: (Float) -> Unit = {},
  onSuccess: (fileSize: Long) -> Unit = {},
  onError: (Throwable) -> Unit = {},
  durationMs: Long,
  frameRate: Int = 30,
  width: Int = 1080,
  height: Int = 1920
)

This function:


The Problem: Bridging Two Worlds

The solution to exporting Lottie animations with audio using Media3 comes down to connecting two seemingly unrelated pieces:

But how do you go from a LottieDrawable — something that draws on a Canvas — to an OpenGL texture ID that Media3 can encode?

The missing link is ImageReader. It gives us a Surface that:

This makes it the perfect bridge between the CPU-based Lottie drawing world and the GPU-based Media3 encoding pipeline.

Screenshot

Control Everything with RawAssetLoader

The RawAssetLoader gives you full manual control over how video and audio data is fed into the Media3 encoder:

.setAssetLoaderFactory(
  RawAssetLoaderFactory(
    audioFormat = audioFormat,
    videoFormat = videoFormat,
    onRawAssetLoaderCreated = { loader -> 
      rawAssetLoaderDeferred.complete(loader) 
    }
  )
)

From here on, you’re responsible for queuing:

Render Lottie to a Hardware Canvas

Each frame of the Lottie animation is generated as a LottieDrawable. The most performant way to get that drawable into a video frame is to draw it into a hardware-accelerated canvas — not into a regular software Bitmap.

val hCanvas = imageReader.surface.lockHardwareCanvas()
try {
  lottieFrame.setBounds(0, 0, videoWidth, videoHeight)
  lottieFrame.draw(hCanvas)
} finally {
  imageReader.surface.unlockCanvasAndPost(hCanvas)
}

This approach is not just efficient — it’s the key to real-time performance. By using lockHardwareCanvas() from an ImageReader.Surface, you automatically get a hardware-backed Canvas that is connected to GPU memory.

✅ Does Lottie use the GPU here?

Yes! Internally, LottieDrawable.draw(Canvas) chooses between a software (renderAndDrawAsBitmap) and hardware (drawDirectlyToCanvas) rendering path.

If the Canvas is hardware-accelerated — as it is when created from lockHardwareCanvas() — Lottie will automatically use GPU rendering via drawDirectlyToCanvas().

Upload Frame as Texture via ImageReader

The unsung hero here is ImageReader. It provides the surface we draw to, and then lets us acquire the drawn image for use with OpenGL:

val imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, maxImages)

Once a frame is drawn, we acquire the image, upload it to an OpenGL texture and and enqueue the texture id returned into the RawAssetLoader:

val image = awaitForLastImage.await()
val textureId = uploadImageToGLTexture(image)

rawAssetLoader.queueInputTexture(textureId, presentationTimeUs)

This texture now represents a video frame and will be encoded by Media3 Transformer.

Decode and Enqueue Audio

In parallel, we decode audio from a URI into raw PCM chunks using a custom AudioDecoder. Each chunk is queued manually into the asset loader:

val (audioChunk, presentationTimeUs) = awaitForAudioChunk.await()

rawAssetLoader.queueAudioData(audioChunk, presentationTimeUs, isLast)

The audio chunks can be read from a local audio file in the traditional, using a MediaExtractor, a MediaCodec and creating a pipeline of small bufferes that are extracted, decoded and then passd to the RawAssetLoader’s callback. Synchronization between video frames and audio chunks is manual — but simple, since you’re in full control.

Bringing It All Together

launch {
  repeat(totalFrames) { frameIndex ->
    // 1. Draw Lottie frame into hardware canvas
    // 2. Acquire image from ImageReader
    // 3. Upload image to GL texture
    // 4. Enqueue texture into Media3
  }
  rawAssetLoader.signalEndOfVideoInput()
}

launch {
  while (!endOfStream) {
    // 1. Decode next PCM chunk
    // 2. Enqueue it to Media3
  }
  rawAssetLoader.signalEndOfAudioInput()
}

In the example code, I have used the coroutine CompletableDeferred calls as semaphores to suspend the thread and wait for the corresponding results in a typesafe way. This design also makes it easy to wrap the process inside a use case that emits a Flow of results.

The full solution can be found in the Github repository: LottieRecorder


Why This Approach Works

Future Improvements

References