Reduce the memory footprint of software rendering (#1973)

As of #1952, Lottie now handles its own bitmaps for software rendering. This was causing an issue for very large compositions. Lottie was creating a bitmap at the size of the entire composition which was often far larger than was needed.

The most common case for this to happen is when people make 1928x1080 compositions in After Effects thinking it's 1:1 with displays. However, Lottie treats dimensions in dp which would cause a 1080p composition to have ~6000x4000 pixel bounds.

With this PR, Lottie will now calculate the largest area that the bitmap can be rendered in given either its bounds, scale, or scaleType and then render a smaller bitmap to a larger area in the original canvas.
diff --git a/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt b/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt
index d6b8698..886747c 100644
--- a/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt
+++ b/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt
@@ -96,7 +96,6 @@
             matrix.preTranslate(translation.x.toFloat(), translation.y.toFloat())
             matrix.preScale(scale.scaleX, scale.scaleY)
 
-
             drawable.composition = composition
             if (dynamicProperties !== setDynamicProperties) {
                 setDynamicProperties?.removeFrom(drawable)
@@ -108,6 +107,7 @@
             drawable.enableMergePathsForKitKatAndAbove(enableMergePaths)
             drawable.useSoftwareRendering(useSoftwareRendering)
             drawable.progress = progress
+            drawable.setBounds(0, 0, composition.bounds.width(), composition.bounds.height())
             drawable.draw(canvas.nativeCanvas, matrix)
         }
     }
diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java b/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
index fc7ee19..5882ef2 100644
--- a/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
+++ b/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
@@ -14,6 +14,7 @@
 import android.graphics.PorterDuff;
 import android.graphics.PorterDuffXfermode;
 import android.graphics.Rect;
+import android.graphics.RectF;
 import android.graphics.Typeface;
 import android.graphics.drawable.Animatable;
 import android.graphics.drawable.Drawable;
@@ -62,7 +63,6 @@
     void run(LottieComposition composition);
   }
 
-  private final Matrix matrix = new Matrix();
   private LottieComposition composition;
   private final LottieValueAnimator animator = new LottieValueAnimator();
   private float scale = 1f;
@@ -106,12 +106,18 @@
   private boolean outlineMasksAndMattes;
   private boolean isApplyingOpacityToLayersEnabled;
 
+  private final Matrix renderingMatrix = new Matrix();
   private boolean softwareRenderingEnabled = false;
   private Bitmap softwareRenderingBitmap;
   private final LPaint softwareRenderingClearPaint = new LPaint();
   private final Canvas softwareRenderingCanvas = new Canvas();
   private Paint softwareRenderingPaint;
-  private final Rect softwareRenderingBoundsRect = new Rect();
+  private Rect softwareRenderingSrcBoundsRect;
+  private Rect softwareRenderingDstBoundsRect;
+  private RectF softwareRenderingDstBoundsRectF;
+  private RectF softwareRenderingTransformedBounds;
+  private Matrix softwareRenderingOriginalCanvasMatrix;
+  private Matrix softwareRenderingOriginalCanvasMatrixInverse;
 
   /**
    * True if the drawable has not been drawn since the last invalidateSelf.
@@ -223,7 +229,7 @@
       return false;
     }
 
-    isDirty = false;
+    isDirty = true;
     clearComposition();
     this.composition = composition;
     buildCompositionLayer();
@@ -1139,10 +1145,16 @@
   @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
   public void draw(Canvas canvas, Matrix matrix) {
     CompositionLayer compositionLayer = this.compositionLayer;
-    if (compositionLayer == null) {
+    LottieComposition composition = this.composition;
+    if (compositionLayer == null || composition == null) {
       return;
     }
-    compositionLayer.draw(canvas, matrix, alpha);
+
+    if (softwareRenderingEnabled) {
+      renderAndDrawAsBitmap(canvas, compositionLayer, matrix);
+    } else {
+      compositionLayer.draw(canvas, matrix, alpha);
+    }
   }
 
   private void drawWithNewAspectRatio(Canvas canvas) {
@@ -1152,35 +1164,34 @@
       return;
     }
 
-    Rect bounds = getBounds();
-    // In fitXY mode, the scale doesn't take effect.
-    float scaleX = bounds.width() / (float) composition.getBounds().width();
-    float scaleY = bounds.height() / (float) composition.getBounds().height();
-
-    matrix.reset();
-    matrix.preScale(scaleX, scaleY);
     if (softwareRenderingEnabled) {
-      renderAndDrawAsBitmap(canvas, compositionLayer, matrix);
+      renderAndDrawAsBitmap(canvas, compositionLayer, null);
     } else {
-      compositionLayer.draw(canvas, matrix, alpha);
+      Rect bounds = getBounds();
+      // In fitXY mode, the scale doesn't take effect.
+      float scaleX = bounds.width() / (float) composition.getBounds().width();
+      float scaleY = bounds.height() / (float) composition.getBounds().height();
+
+      renderingMatrix.reset();
+      renderingMatrix.preScale(scaleX, scaleY);
+      compositionLayer.draw(canvas, renderingMatrix, alpha);
     }
   }
 
   private void drawWithOriginalAspectRatio(Canvas canvas) {
     CompositionLayer compositionLayer = this.compositionLayer;
     LottieComposition composition = this.composition;
+    float scale = this.scale;
     if (compositionLayer == null || composition == null) {
       return;
     }
 
-    float scale = this.scale;
-
-    matrix.reset();
-    matrix.preScale(scale, scale);
     if (softwareRenderingEnabled) {
-      renderAndDrawAsBitmap(canvas, compositionLayer, matrix);
+      renderAndDrawAsBitmap(canvas, compositionLayer, null);
     } else {
-      compositionLayer.draw(canvas, matrix, alpha);
+      renderingMatrix.reset();
+      renderingMatrix.preScale(scale, scale);
+      compositionLayer.draw(canvas, renderingMatrix, alpha);
     }
   }
 
@@ -1191,33 +1202,95 @@
    * @see LottieDrawable#useSoftwareRendering(boolean)
    * @see LottieAnimationView#setRenderMode(RenderMode)
    */
-  private void renderAndDrawAsBitmap(Canvas originalCanvas, CompositionLayer compositionLayer, Matrix matrix) {
-    if (softwareRenderingPaint == null) {
-      softwareRenderingPaint = new LPaint();
+  private void renderAndDrawAsBitmap(Canvas originalCanvas, CompositionLayer compositionLayer, @Nullable Matrix parentMatrix) {
+    ensureSoftwareRenderingObjectsInitialized();
+
+    //noinspection deprecation
+    originalCanvas.getMatrix(softwareRenderingOriginalCanvasMatrix);
+    softwareRenderingOriginalCanvasMatrix.invert(softwareRenderingOriginalCanvasMatrixInverse);
+    renderingMatrix.set(softwareRenderingOriginalCanvasMatrix);
+    if (parentMatrix != null) {
+      renderingMatrix.postConcat(parentMatrix);
     }
 
-    int intrinsicWidth = getIntrinsicWidth();
-    int intrinsicHeight = getIntrinsicHeight();
+    // Determine what bounds the animation will render to after taking into account the canvas and parent matrix.
+    softwareRenderingTransformedBounds.set(0f, 0f, getIntrinsicWidth(), getIntrinsicHeight());
+    renderingMatrix.mapRect(softwareRenderingTransformedBounds);
 
-    if (softwareRenderingBitmap == null ||
-        softwareRenderingBitmap.getWidth() < intrinsicWidth ||
-        softwareRenderingBitmap.getHeight() < intrinsicHeight) {
-      softwareRenderingBitmap = Bitmap.createBitmap(intrinsicWidth, intrinsicHeight, Bitmap.Config.ARGB_8888);
-      softwareRenderingCanvas.setBitmap(softwareRenderingBitmap);
-      softwareRenderingClearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
-      softwareRenderingClearPaint.setColor(Color.BLACK);
+    // We only need to render the portion of the animation that intersects with the canvas's bounds.
+    softwareRenderingTransformedBounds.intersect(0f, 0f, originalCanvas.getWidth(), originalCanvas.getHeight());
+
+    int renderWidth = (int) Math.ceil(softwareRenderingTransformedBounds.width());
+    int renderHeight = (int) Math.ceil(softwareRenderingTransformedBounds.height());
+
+    if (renderWidth == 0 || renderHeight == 0) {
+      return;
     }
 
+    ensureSoftwareRenderingBitmap(renderWidth, renderHeight);
+
+    softwareRenderingSrcBoundsRect.set(0, 0, renderWidth, renderHeight);
+
     if (isDirty) {
-      if (softwareRenderingBitmap.getWidth() == intrinsicWidth && softwareRenderingBitmap.getHeight() == intrinsicHeight) {
-        // eraseColor is ~10% faster than drawRect when covering the entire bitmap.
-        softwareRenderingBitmap.eraseColor(0);
-      } else {
-        softwareRenderingCanvas.drawRect(0f, 0f, intrinsicWidth, intrinsicHeight, softwareRenderingClearPaint);
-      }
-      compositionLayer.draw(softwareRenderingCanvas, matrix, alpha);
-      softwareRenderingBoundsRect.set(0, 0, intrinsicWidth, intrinsicHeight);
+      softwareRenderingBitmap.eraseColor(0);
+      renderingMatrix.preScale(scale, scale);
+      // The bounds are usually intrinsicWidth x intrinsicHeight. If they are different, an external source is scaling this drawable.
+      // This is how ImageView.ScaleType.FIT_XY works.
+      renderingMatrix.preScale(getBounds().width() / (float) getIntrinsicWidth(), getBounds().height() / (float) getIntrinsicHeight());
+      // We want to render the smallest bitmap possible. If the animation doesn't start at the top left, we translate the canvas and shrink the
+      // bitmap to avoid allocating and copying the empty space on the left and top. renderWidth and renderHeight take this into account.
+      renderingMatrix.postTranslate(-softwareRenderingTransformedBounds.left, -softwareRenderingTransformedBounds.top);
+      compositionLayer.draw(softwareRenderingCanvas, renderingMatrix, alpha);
+
+      // Calculate the dst bounds.
+      // We need to map the rendered coordinates back to the canvas's coordinates. To do so, we need to invert the transform
+      // of the original canvas.
+      // Take the bounds of the rendered animation and map them to the canvas's coordinates.
+      // This is similar to the src rect above but the src bound may have a left and top offset.
+      softwareRenderingOriginalCanvasMatrixInverse.mapRect(softwareRenderingDstBoundsRectF, softwareRenderingTransformedBounds);
+      convertRect(softwareRenderingDstBoundsRectF, softwareRenderingDstBoundsRect);
     }
-    originalCanvas.drawBitmap(softwareRenderingBitmap, softwareRenderingBoundsRect, softwareRenderingBoundsRect, softwareRenderingPaint);
+    originalCanvas.drawBitmap(softwareRenderingBitmap, softwareRenderingSrcBoundsRect, softwareRenderingDstBoundsRect, softwareRenderingPaint);
+  }
+
+  private void ensureSoftwareRenderingObjectsInitialized() {
+    if (softwareRenderingPaint != null) {
+      return;
+    }
+    softwareRenderingPaint = new LPaint();
+    softwareRenderingClearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
+    softwareRenderingClearPaint.setColor(Color.BLACK);
+    softwareRenderingSrcBoundsRect = new Rect();
+    softwareRenderingDstBoundsRect = new Rect();
+    softwareRenderingDstBoundsRectF = new RectF();
+    softwareRenderingTransformedBounds = new RectF();
+    softwareRenderingOriginalCanvasMatrix = new Matrix();
+    softwareRenderingOriginalCanvasMatrixInverse = new Matrix();
+  }
+
+  private void ensureSoftwareRenderingBitmap(int renderWidth, int renderHeight) {
+    if (softwareRenderingBitmap == null ||
+        softwareRenderingBitmap.getWidth() < renderWidth ||
+        softwareRenderingBitmap.getHeight() < renderHeight) {
+      softwareRenderingBitmap = Bitmap.createBitmap(renderWidth, renderHeight, Bitmap.Config.ARGB_8888);
+      softwareRenderingCanvas.setBitmap(softwareRenderingBitmap);
+      isDirty = true;
+    } else if (softwareRenderingBitmap.getWidth() > renderWidth || softwareRenderingBitmap.getHeight() > renderHeight) {
+      softwareRenderingBitmap = Bitmap.createBitmap(softwareRenderingBitmap, 0, 0, renderWidth, renderHeight);
+      softwareRenderingCanvas.setBitmap(softwareRenderingBitmap);
+      isDirty = true;
+    }
+  }
+
+  /**
+   * Convert a RectF to a Rect
+   */
+  private void convertRect(RectF src, Rect dst) {
+    dst.set(
+        (int) Math.floor(src.left),
+        (int) Math.floor(src.top),
+        (int) Math.ceil(src.right),
+        (int) Math.ceil(src.bottom)
+    );
   }
 }
diff --git a/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java b/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java
index 8c1f315..14b1c66 100644
--- a/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java
+++ b/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java
@@ -77,12 +77,14 @@
 
   private final Path path = new Path();
   private final Matrix matrix = new Matrix();
+  private final Matrix canvasMatrix = new Matrix();
   private final Paint contentPaint = new LPaint(Paint.ANTI_ALIAS_FLAG);
   private final Paint dstInPaint = new LPaint(Paint.ANTI_ALIAS_FLAG, PorterDuff.Mode.DST_IN);
   private final Paint dstOutPaint = new LPaint(Paint.ANTI_ALIAS_FLAG, PorterDuff.Mode.DST_OUT);
   private final Paint mattePaint = new LPaint(Paint.ANTI_ALIAS_FLAG);
   private final Paint clearPaint = new LPaint(PorterDuff.Mode.CLEAR);
   private final RectF rect = new RectF();
+  private final RectF canvasBounds = new RectF();
   private final RectF maskBoundsRect = new RectF();
   private final RectF matteBoundsRect = new RectF();
   private final RectF tempMaskBoundsRect = new RectF();
@@ -259,7 +261,17 @@
     matrix.preConcat(transform.getMatrix());
     intersectBoundsWithMask(rect, matrix);
 
-    if (!rect.intersect(0, 0, canvas.getWidth(), canvas.getHeight())) {
+    // Intersect the mask and matte rect with the canvas bounds.
+    // If the canvas has a transform, then we need to transform its bounds by its matrix
+    // so that we know the coordinate space that the canvas is showing.
+    canvasBounds.set(0f, 0f, canvas.getWidth(), canvas.getHeight());
+    //noinspection deprecation
+    canvas.getMatrix(canvasMatrix);
+    if (!canvasMatrix.isIdentity()) {
+      canvasMatrix.invert(canvasMatrix);
+      canvasMatrix.mapRect(canvasBounds);
+    }
+    if (!rect.intersect(canvasBounds)) {
       rect.set(0, 0, 0, 0);
     }
 
diff --git a/snapshot-tests/build.gradle b/snapshot-tests/build.gradle
index 243105b..ee8ed56 100644
--- a/snapshot-tests/build.gradle
+++ b/snapshot-tests/build.gradle
@@ -65,6 +65,7 @@
   implementation 'androidx.appcompat:appcompat:1.4.0-beta01'
   implementation "androidx.compose.ui:ui:$composeVersion"
   implementation "androidx.compose.ui:ui-tooling:$composeVersion"
+  implementation "androidx.compose.material:material:$composeVersion"
 
   implementation 'com.squareup.okhttp3:okhttp:4.9.1'
 
diff --git a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/LottieSnapshotTest.kt b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/LottieSnapshotTest.kt
index 8ec706b..6df7cd4 100644
--- a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/LottieSnapshotTest.kt
+++ b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/LottieSnapshotTest.kt
@@ -1,7 +1,9 @@
 package com.airbnb.lottie.snapshots
 
 import android.Manifest
+import android.content.ComponentCallbacks2
 import android.content.Context
+import android.content.res.Configuration
 import android.util.Log
 import android.view.View
 import android.widget.FrameLayout
@@ -19,6 +21,7 @@
 import com.airbnb.lottie.snapshots.tests.DynamicPropertiesTestCase
 import com.airbnb.lottie.snapshots.tests.FailureTestCase
 import com.airbnb.lottie.snapshots.tests.FrameBoundariesTestCase
+import com.airbnb.lottie.snapshots.tests.LargeCompositionSoftwareRendering
 import com.airbnb.lottie.snapshots.tests.MarkersTestCase
 import com.airbnb.lottie.snapshots.tests.NightModeTestCase
 import com.airbnb.lottie.snapshots.tests.OutlineMasksAndMattesTestCase
@@ -53,22 +56,21 @@
         Manifest.permission.READ_EXTERNAL_STORAGE
     )
 
+    lateinit var testCaseContext: SnapshotTestCaseContext
+    lateinit var snapshotter: HappoSnapshotter
+
     @Before
     fun setup() {
         LottieCompositionCache.getInstance().resize(1)
-    }
-
-    @Test
-    fun testAll() = runBlocking {
         val context = ApplicationProvider.getApplicationContext<Context>()
-        val snapshotter = HappoSnapshotter(context) { name, variant ->
+        snapshotter = HappoSnapshotter(context) { name, variant ->
             snapshotActivityRule.scenario.onActivity { activity ->
                 activity.updateUiForSnapshot(name, variant)
             }
         }
-        val testCaseContext: SnapshotTestCaseContext = object : SnapshotTestCaseContext {
+        testCaseContext = object : SnapshotTestCaseContext {
             override val context: Context = context
-            override val snapshotter: HappoSnapshotter = snapshotter
+            override val snapshotter: HappoSnapshotter = this@LottieSnapshotTest.snapshotter
             override val bitmapPool: BitmapPool = BitmapPool()
             override val animationViewPool: ObjectPool<LottieAnimationView> = ObjectPool {
                 val animationViewContainer = FrameLayout(context)
@@ -77,12 +79,31 @@
                 }
             }
             override val filmStripViewPool: ObjectPool<FilmStripView> = ObjectPool {
-                FilmStripView(context).apply {
-                    setLayerType(View.LAYER_TYPE_NONE, null)
-                }
+                FilmStripView(context)
+            }
+
+            override fun onActivity(callback: (SnapshotTestActivity) -> Unit) {
+                snapshotActivityRule.scenario.onActivity(callback)
             }
         }
-        val prodAnimations = ProdAnimationsTestCase()
+        snapshotActivityRule.scenario.onActivity { activity ->
+            activity.registerComponentCallbacks(object : ComponentCallbacks2 {
+                override fun onConfigurationChanged(newConfig: Configuration) {}
+
+                override fun onLowMemory() {
+                    testCaseContext.bitmapPool.clear()
+                }
+
+                override fun onTrimMemory(level: Int) {
+                    testCaseContext.bitmapPool.clear()
+                }
+
+            })
+        }
+    }
+
+    @Test
+    fun testAll() = runBlocking {
         val testCases = listOf(
             CustomBoundsTestCase(),
             ColorStateListColorFilterTestCase(),
@@ -97,12 +118,13 @@
             NightModeTestCase(),
             ApplyOpacityToLayerTestCase(),
             OutlineMasksAndMattesTestCase(),
-            prodAnimations,
+            LargeCompositionSoftwareRendering(),
+            ProdAnimationsTestCase(),
         )
 
         withTimeout(TimeUnit.MINUTES.toMillis(45)) {
             launch {
-                with(prodAnimations) {
+                with(testCases.filterIsInstance<ProdAnimationsTestCase>().firstOrNull() ?: return@launch) {
                     // Kick off the downloads ahead of time so it can start while the other tests are snapshotting
                     testCaseContext.downloadAnimations()
                 }
diff --git a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/SnapshotTestCaseContext.kt b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/SnapshotTestCaseContext.kt
index 704dbdb..f254de1 100644
--- a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/SnapshotTestCaseContext.kt
+++ b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/SnapshotTestCaseContext.kt
@@ -1,6 +1,7 @@
 package com.airbnb.lottie.snapshots
 
 import android.content.Context
+import android.graphics.Bitmap
 import android.graphics.BitmapFactory
 import android.graphics.Canvas
 import android.graphics.Color
@@ -10,6 +11,12 @@
 import android.view.ViewGroup
 import android.widget.FrameLayout
 import android.widget.ImageView
+import android.widget.LinearLayout
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.ComposeView
+import androidx.core.view.doOnAttach
+import androidx.core.view.doOnLayout
+import androidx.core.view.doOnPreDraw
 import com.airbnb.lottie.FontAssetDelegate
 import com.airbnb.lottie.LottieAnimationView
 import com.airbnb.lottie.LottieComposition
@@ -20,7 +27,13 @@
 import com.airbnb.lottie.snapshots.utils.HappoSnapshotter
 import com.airbnb.lottie.snapshots.utils.ObjectPool
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.android.awaitFrame
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.withContext
+import kotlin.coroutines.resume
+
+private val ActivityContentLock = Mutex()
 
 /**
  * Set of properties that are available to all [SnapshotTestCase] runs.
@@ -31,6 +44,7 @@
     val bitmapPool: BitmapPool
     val animationViewPool: ObjectPool<LottieAnimationView>
     val filmStripViewPool: ObjectPool<FilmStripView>
+    fun onActivity(callback: (SnapshotTestActivity) -> Unit)
 }
 
 @Suppress("unused")
@@ -62,6 +76,8 @@
     assetName: String,
     snapshotName: String = assetName,
     snapshotVariant: String = "default",
+    widthPx: Int = context.resources.displayMetrics.widthPixels,
+    heightPx: Int = context.resources.displayMetrics.heightPixels,
     callback: (LottieAnimationView) -> Unit,
 ) {
     val result = LottieCompositionFactory.fromAssetSync(context, assetName)
@@ -72,16 +88,15 @@
     animationView.scale = 1f
     animationView.scaleType = ImageView.ScaleType.FIT_CENTER
     callback(animationView)
-    val widthSpec = View.MeasureSpec.makeMeasureSpec(
-        context.resources.displayMetrics
-            .widthPixels,
-        View.MeasureSpec.EXACTLY
-    )
-    val heightSpec = View.MeasureSpec.makeMeasureSpec(
-        context.resources.displayMetrics
-            .heightPixels, View.MeasureSpec.EXACTLY
-    )
     val animationViewContainer = animationView.parent as ViewGroup
+    val widthSpec = View.MeasureSpec.makeMeasureSpec(
+        widthPx,
+        View.MeasureSpec.EXACTLY,
+    )
+    val heightSpec: Int = View.MeasureSpec.makeMeasureSpec(
+        heightPx,
+        View.MeasureSpec.EXACTLY,
+    )
     animationViewContainer.measure(widthSpec, heightSpec)
     animationViewContainer.layout(0, 0, animationViewContainer.measuredWidth, animationViewContainer.measuredHeight)
     val bitmap = bitmapPool.acquire(animationView.width, animationView.height)
@@ -137,3 +152,34 @@
     snapshotter.record(bitmap, name, variant)
     bitmapPool.release(bitmap)
 }
+
+suspend fun SnapshotTestCaseContext.snapshotComposable(
+    name: String,
+    variant: String = "default",
+    content: @Composable () -> Unit,
+) = withContext(Dispatchers.Default) {
+    log("Snapshotting $name")
+    val composeView = ComposeView(context)
+    composeView.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)
+    val bitmap = withContext(Dispatchers.Main) {
+        composeView.setContent(content)
+        suspendCancellableCoroutine<Bitmap> { cont ->
+            composeView.doOnLayout {
+                log("Drawing $name")
+                val bitmap = bitmapPool.acquire(composeView.width, composeView.height)
+                val canvas = Canvas(bitmap)
+                composeView.draw(canvas)
+                cont.resume(bitmap)
+            }
+            onActivity { activity ->
+                activity.binding.content.addView(composeView)
+            }
+        }
+    }
+    onActivity { activity ->
+        activity.binding.content.removeView(composeView)
+    }
+    LottieCompositionCache.getInstance().clear()
+    snapshotter.record(bitmap, name, variant)
+    bitmapPool.release(bitmap)
+}
\ No newline at end of file
diff --git a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/tests/LargeCompositionSoftwareRendering.kt b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/tests/LargeCompositionSoftwareRendering.kt
new file mode 100644
index 0000000..cdce72f
--- /dev/null
+++ b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/tests/LargeCompositionSoftwareRendering.kt
@@ -0,0 +1,106 @@
+package com.airbnb.lottie.snapshots.tests
+
+import android.graphics.Matrix
+import android.widget.ImageView
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+import com.airbnb.lottie.LottieAnimationView
+import com.airbnb.lottie.LottieComposition
+import com.airbnb.lottie.LottieCompositionFactory
+import com.airbnb.lottie.compose.LottieAnimation
+import com.airbnb.lottie.compose.LottieCompositionSpec
+import com.airbnb.lottie.compose.rememberLottieComposition
+import com.airbnb.lottie.snapshots.SnapshotTestCase
+import com.airbnb.lottie.snapshots.SnapshotTestCaseContext
+import com.airbnb.lottie.snapshots.snapshotComposable
+import com.airbnb.lottie.snapshots.withAnimationView
+
+class LargeCompositionSoftwareRendering : SnapshotTestCase {
+    override suspend fun SnapshotTestCaseContext.run() {
+        snapshotWithImageView("Default") {}
+        snapshotWithImageView("CenterCrop") { av ->
+            av.scaleType = ImageView.ScaleType.CENTER_CROP
+        }
+        snapshotWithImageView("Center") { av ->
+            av.scaleType = ImageView.ScaleType.CENTER
+        }
+        snapshotWithImageView("Center With Scale") { av ->
+            av.scaleType = ImageView.ScaleType.CENTER
+            av.scale = 0.08f
+        }
+        snapshotWithImageView("CenterInside") { av ->
+            av.scaleType = ImageView.ScaleType.CENTER_INSIDE
+        }
+        snapshotWithImageView("FitCenter") { av ->
+            av.scaleType = ImageView.ScaleType.FIT_CENTER
+        }
+        snapshotWithImageView("FitStart") { av ->
+            av.scaleType = ImageView.ScaleType.FIT_START
+        }
+        snapshotWithImageView("FitEnd") { av ->
+            av.scaleType = ImageView.ScaleType.FIT_END
+        }
+        snapshotWithImageView("FitXY") { av ->
+            av.scaleType = ImageView.ScaleType.FIT_XY
+        }
+        snapshotWithImageView("Matrix With Sew") { av ->
+            av.scaleType = ImageView.ScaleType.MATRIX
+            av.imageMatrix = Matrix().apply {
+                preScale(0.025f, 0.025f)
+                preSkew(1f, 0f)
+            }
+        }
+
+        snapshotWithComposable("Fit") { comp ->
+            LottieAnimation(comp, progress = 0f, contentScale = ContentScale.Fit)
+        }
+        snapshotWithComposable("Crop") { comp ->
+            LottieAnimation(comp, progress = 0f, contentScale = ContentScale.Crop)
+        }
+        snapshotWithComposable("FillBounds") { comp ->
+            LottieAnimation(comp, progress = 0f, contentScale = ContentScale.FillBounds)
+        }
+        snapshotWithComposable("FillWidth") { comp ->
+            LottieAnimation(comp, progress = 0f, contentScale = ContentScale.FillWidth)
+        }
+        snapshotWithComposable("FillHeight") { comp ->
+            LottieAnimation(comp, progress = 0f, contentScale = ContentScale.FillHeight)
+        }
+        snapshotWithComposable("Inside") { comp ->
+            LottieAnimation(comp, progress = 0f, contentScale = ContentScale.Inside)
+        }
+        snapshotWithComposable("None") { comp ->
+            LottieAnimation(comp, progress = 0f, contentScale = ContentScale.None)
+        }
+    }
+
+    private suspend fun SnapshotTestCaseContext.snapshotWithImageView(snapshotVariant: String, callback: (LottieAnimationView) -> Unit) {
+        withAnimationView("Tests/LargeComposition.json", "Large Composition Tests", snapshotVariant, widthPx = 275, heightPx = 275) { av ->
+            av.setBackgroundColor(0x7f7f7f7f)
+            callback(av)
+        }
+    }
+
+    private suspend fun SnapshotTestCaseContext.snapshotWithComposable(
+        snapshotVariant: String,
+        callback: @Composable (composition: LottieComposition) -> Unit
+    ) {
+        val composition = LottieCompositionFactory.fromAssetSync(context, "Tests/LargeComposition.json").value!!
+        snapshotComposable("Large Composition Tests - Compose", snapshotVariant) {
+            Box(
+                modifier = Modifier
+                    .size(100.dp)
+                    .background(Color.Gray)
+            ) {
+                callback(composition)
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/BitmapPool.kt b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/BitmapPool.kt
index 84e3f4f..aae3c24 100644
--- a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/BitmapPool.kt
+++ b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/BitmapPool.kt
@@ -15,21 +15,20 @@
     private val semaphore = SuspendingSemaphore(MAX_RELEASED_BITMAPS)
     private val bitmaps = Collections.synchronizedList(ArrayList<Bitmap>())
     private val releasedBitmaps = ConcurrentHashMap<Bitmap, Bitmap>()
-    private val clearPaint by lazy {
-        Paint().apply {
-            xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
-        }
-    }
 
+    @Synchronized
     fun clear() {
         bitmaps.clear()
     }
 
-    @ExperimentalCoroutinesApi
+    @Synchronized
     fun acquire(width: Int, height: Int): Bitmap {
         if (width <= 0 || height <= 0) {
             return TRANSPARENT_1X1_BITMAP
         }
+        if (width > 1000 || height > 1000) {
+            Log.d(L.TAG, "Requesting a large bitmap for " + width + "x" + height)
+        }
 
         val blockedStartTime = System.currentTimeMillis()
         semaphore.acquire()
@@ -40,26 +39,24 @@
 
         val bitmap = synchronized(bitmaps) {
             bitmaps
-                    .firstOrNull { it.width >= width && it.height >= height }
-                    ?.also { bitmaps.remove(it) }
+                .firstOrNull { it.width >= width && it.height >= height }
+                ?.also { bitmaps.remove(it) }
         } ?: createNewBitmap(width, height)
 
         val croppedBitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height)
         releasedBitmaps[croppedBitmap] = bitmap
 
-        Canvas(croppedBitmap).apply {
-            drawRect(0f, 0f, croppedBitmap.width.toFloat(), croppedBitmap.height.toFloat(), clearPaint)
-        }
-
         return croppedBitmap
     }
 
+    @Synchronized
     fun release(bitmap: Bitmap) {
         if (bitmap == TRANSPARENT_1X1_BITMAP) {
             return
         }
 
         val originalBitmap = releasedBitmaps.remove(bitmap) ?: throw IllegalArgumentException("Unable to find original bitmap.")
+        originalBitmap.eraseColor(0)
 
         bitmaps += originalBitmap
         semaphore.release()
diff --git a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/ObjectPool.kt b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/ObjectPool.kt
index b4c430a..16f8f36 100644
--- a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/ObjectPool.kt
+++ b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/ObjectPool.kt
@@ -12,7 +12,6 @@
     private val objects = Collections.synchronizedList(ArrayList<T>())
     private val releasedObjects = HashSet<T>()
 
-    @ExperimentalCoroutinesApi
     @Synchronized
     fun acquire(): T {
         val blockedStartTime = System.currentTimeMillis()
diff --git a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/Utils.kt b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/Utils.kt
index d2e1baf..5501510 100644
--- a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/Utils.kt
+++ b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/Utils.kt
@@ -7,27 +7,31 @@
 import com.amazonaws.mobileconnectors.s3.transferutility.TransferState
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.delay
+import kotlinx.coroutines.suspendCancellableCoroutine
 import kotlin.coroutines.resume
 import kotlin.coroutines.resumeWithException
 import kotlin.coroutines.suspendCoroutine
 
-suspend fun TransferObserver.await() = suspendCoroutine<TransferObserver> { continuation ->
+suspend fun TransferObserver.await() = suspendCancellableCoroutine<TransferObserver> { continuation ->
     val listener = object : TransferListener {
         override fun onProgressChanged(id: Int, bytesCurrent: Long, bytesTotal: Long) {}
 
         override fun onError(id: Int, ex: Exception) {
             Log.e(L.TAG, "$id failed uploading!", ex)
             continuation.resumeWithException(ex)
+            cleanTransferListener()
         }
 
         override fun onStateChanged(id: Int, state: TransferState) {
             when (state) {
                 TransferState.COMPLETED -> {
                     continuation.resume(this@await)
+                    cleanTransferListener()
                 }
                 TransferState.CANCELED, TransferState.FAILED -> {
                     Log.d(L.TAG, "$id failed uploading ($state).")
                     continuation.resume(this@await)
+                    cleanTransferListener()
                 }
                 else -> Unit
             }
diff --git a/snapshot-tests/src/main/assets/Tests/LargeComposition.json b/snapshot-tests/src/main/assets/Tests/LargeComposition.json
new file mode 100644
index 0000000..6c07daa
--- /dev/null
+++ b/snapshot-tests/src/main/assets/Tests/LargeComposition.json
@@ -0,0 +1 @@
+{"v":"5.8.1","fr":25,"ip":0,"op":2,"w":2000,"h":4000,"nm":"Comp 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[1000,2000,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[1554.562,597.062],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.220496237278,1,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1.281,-21.469],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 2","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[1780.188,3801.75],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.116727701823,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[2.094,12.875],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":2,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":1,"nm":"Red Solid 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[1000,2000,0],"ix":2,"l":2},"a":{"a":0,"k":[1000,2000,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"sw":2000,"sh":4000,"sc":"#ff0000","ip":0,"op":2,"st":0,"bm":0}],"markers":[]}
\ No newline at end of file
diff --git a/snapshot-tests/src/main/java/com/airbnb/lottie/snapshots/SnapshotTestActivity.kt b/snapshot-tests/src/main/java/com/airbnb/lottie/snapshots/SnapshotTestActivity.kt
index aedceb3..1f5c8be 100644
--- a/snapshot-tests/src/main/java/com/airbnb/lottie/snapshots/SnapshotTestActivity.kt
+++ b/snapshot-tests/src/main/java/com/airbnb/lottie/snapshots/SnapshotTestActivity.kt
@@ -5,7 +5,7 @@
 import com.airbnb.lottie.snapshots.utils.viewBinding
 
 class SnapshotTestActivity : AppCompatActivity() {
-    private val binding: SnapshotTestsActivityBinding by viewBinding()
+    val binding: SnapshotTestsActivityBinding by viewBinding()
 
     fun updateUiForSnapshot(snapshotName: String, snapshotVariant: String) {
         binding.counterTextView.post {
diff --git a/snapshot-tests/src/main/res/layout/film_strip_view.xml b/snapshot-tests/src/main/res/layout/film_strip_view.xml
index 1705fbf..26ebc8e 100644
--- a/snapshot-tests/src/main/res/layout/film_strip_view.xml
+++ b/snapshot-tests/src/main/res/layout/film_strip_view.xml
@@ -1,5 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <GridLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
     android:id="@+id/grid_layout"
     android:layout_width="wrap_content"
     android:layout_height="wrap_content"
@@ -9,45 +10,54 @@
     <com.airbnb.lottie.snapshots.NoCacheLottieAnimationView
         android:id="@+id/animation_1"
         android:layout_width="@dimen/film_strip_size"
-        android:layout_height="@dimen/film_strip_size" />
+        android:layout_height="@dimen/film_strip_size"
+        app:lottie_renderMode="software" />
 
     <com.airbnb.lottie.snapshots.NoCacheLottieAnimationView
         android:id="@+id/animation_2"
         android:layout_width="@dimen/film_strip_size"
-        android:layout_height="@dimen/film_strip_size" />
+        android:layout_height="@dimen/film_strip_size"
+        app:lottie_renderMode="software" />
 
     <com.airbnb.lottie.snapshots.NoCacheLottieAnimationView
         android:id="@+id/animation_3"
         android:layout_width="@dimen/film_strip_size"
-        android:layout_height="@dimen/film_strip_size" />
+        android:layout_height="@dimen/film_strip_size"
+        app:lottie_renderMode="software" />
 
     <com.airbnb.lottie.snapshots.NoCacheLottieAnimationView
         android:id="@+id/animation_4"
         android:layout_width="@dimen/film_strip_size"
-        android:layout_height="@dimen/film_strip_size" />
+        android:layout_height="@dimen/film_strip_size"
+        app:lottie_renderMode="software" />
 
     <com.airbnb.lottie.snapshots.NoCacheLottieAnimationView
         android:id="@+id/animation_5"
         android:layout_width="@dimen/film_strip_size"
-        android:layout_height="@dimen/film_strip_size" />
+        android:layout_height="@dimen/film_strip_size"
+        app:lottie_renderMode="software" />
 
     <com.airbnb.lottie.snapshots.NoCacheLottieAnimationView
         android:id="@+id/animation_6"
         android:layout_width="@dimen/film_strip_size"
-        android:layout_height="@dimen/film_strip_size" />
+        android:layout_height="@dimen/film_strip_size"
+        app:lottie_renderMode="software" />
 
     <com.airbnb.lottie.snapshots.NoCacheLottieAnimationView
         android:id="@+id/animation_7"
         android:layout_width="@dimen/film_strip_size"
-        android:layout_height="@dimen/film_strip_size" />
+        android:layout_height="@dimen/film_strip_size"
+        app:lottie_renderMode="hardware" />
 
     <com.airbnb.lottie.snapshots.NoCacheLottieAnimationView
         android:id="@+id/animation_8"
         android:layout_width="@dimen/film_strip_size"
-        android:layout_height="@dimen/film_strip_size" />
+        android:layout_height="@dimen/film_strip_size"
+        app:lottie_renderMode="hardware" />
 
     <com.airbnb.lottie.snapshots.NoCacheLottieAnimationView
         android:id="@+id/animation_9"
         android:layout_width="@dimen/film_strip_size"
-        android:layout_height="@dimen/film_strip_size" />
+        android:layout_height="@dimen/film_strip_size"
+        app:lottie_renderMode="hardware" />
 </GridLayout>
diff --git a/snapshot-tests/src/main/res/layout/snapshot_tests_activity.xml b/snapshot-tests/src/main/res/layout/snapshot_tests_activity.xml
index 20f5384..93421bf 100644
--- a/snapshot-tests/src/main/res/layout/snapshot_tests_activity.xml
+++ b/snapshot-tests/src/main/res/layout/snapshot_tests_activity.xml
@@ -6,17 +6,29 @@
     android:orientation="vertical"
     android:padding="24dp">
 
-    <TextView
-        android:id="@+id/statusTextView"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:textSize="10sp"
-        tools:text="com.airbnb.android" />
+    <FrameLayout
+        android:id="@+id/content"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1" />
 
     <TextView
         android:id="@+id/counterTextView"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
+        android:paddingEnd="24dp"
+        android:paddingStart="24dp"
+        android:paddingTop="8dp"
         android:text="0"
         android:textSize="10sp" />
+
+    <TextView
+        android:id="@+id/statusTextView"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:paddingEnd="24dp"
+        android:paddingStart="24dp"
+        android:paddingTop="24dp"
+        android:textSize="10sp"
+        tools:text="com.airbnb.android" />
 </LinearLayout>
\ No newline at end of file