[skif] Add applyColorFilter to FilterResult

Adds applyColorFilter() to FilterResult, which attempts to compose any
new color filter with a prior CF, as well as other effects like crops
and transforms.

This lifts the SkSpecialImage subsetting into utility function so it
can be used outside of just resolve(). It lifts the layer bounds crop
detection that had been in applyTransform() into a new isCropped()
function.

The various composition logics in applyTransform() and
applyCrop() have been updated to factor in the possibility of a color
filter (particularly one that affects transparent black).

FilterResultTest harness is updated to have applyColorFilter() as well
as updated desired-output calculations when an expected image could
technically be "infinite" and it's not usually doing back propagation
(which always restricts infinite bounds).

Adds many unit tests for color filters and how they interact with
transforms and crops. In many cases because of bounds propagation and
the tight contains checks, transparency-affecting color filters can
still avoid an intermediate image.

Other than these unit tests, nothing uses applyColorFilter() directly
although it will be used to simplify SkColorFilterImageFilter in the
next CL.

Bug: skia:9283
Change-Id: I91e3a51f2d67100c7748444c112bda7413400e4e
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/683199
Commit-Queue: Michael Ludwig <michaelludwig@google.com>
Reviewed-by: Robert Phillips <robertphillips@google.com>
diff --git a/src/core/SkImageFilterTypes.cpp b/src/core/SkImageFilterTypes.cpp
index cbe679d..eca879e 100644
--- a/src/core/SkImageFilterTypes.cpp
+++ b/src/core/SkImageFilterTypes.cpp
@@ -7,6 +7,9 @@
 
 #include "src/core/SkImageFilterTypes.h"
 
+#include "include/core/SkShader.h"
+#include "include/core/SkTileMode.h"
+#include "src/core/SkColorFilterBase.h"
 #include "src/core/SkImageFilter_Base.h"
 #include "src/core/SkMatrixPriv.h"
 #include "src/core/SkRectPriv.h"
@@ -33,6 +36,10 @@
     return v;
 }
 
+static bool fills_layer_bounds(const SkColorFilter* colorFilter) {
+    return colorFilter && as_CFB(colorFilter)->affectsTransparentBlack();
+}
+
 // If m is epsilon within the form [1 0 tx], this returns true and sets out to [tx, ty]
 //                                 [0 1 ty]
 //                                 [0 0 1 ]
@@ -57,6 +64,31 @@
     return true;
 }
 
+// Assumes 'image' is decal-tiled, so everything outside the image bounds but inside dstBounds is
+// transparent black, in which case the returned special image may be smaller than dstBounds.
+static std::pair<sk_sp<SkSpecialImage>, skif::LayerSpace<SkIPoint>> extract_subset(
+        const SkSpecialImage* image,
+        skif::LayerSpace<SkIPoint> origin,
+        skif::LayerSpace<SkIRect> dstBounds) {
+    skif::LayerSpace<SkIRect> imageBounds(SkIRect::MakeXYWH(origin.x(), origin.y(),
+                                          image->width(), image->height()));
+    if (!imageBounds.intersect(dstBounds)) {
+        return {nullptr, {}};
+    }
+
+    // Offset the image subset directly to avoid issues negating (origin). With the prior
+    // intersection (bounds - origin) will be >= 0, but (bounds + (-origin)) may not, (e.g.
+    // origin is INT_MIN).
+    SkIRect subset = { imageBounds.left() - origin.x(),
+                       imageBounds.top() - origin.y(),
+                       imageBounds.right() - origin.x(),
+                       imageBounds.bottom() - origin.y() };
+    SkASSERT(subset.fLeft >= 0 && subset.fTop >= 0 &&
+             subset.fRight <= image->width() && subset.fBottom <= image->height());
+
+    return {image->makeSubset(subset), imageBounds.topLeft()};
+}
+
 static SkRect map_rect(const SkMatrix& matrix, const SkRect& rect) {
     if (rect.isEmpty()) {
         return SkRect::MakeEmpty();
@@ -321,31 +353,129 @@
     return image;
 }
 
+bool FilterResult::isCropped(const LayerSpace<SkMatrix>& xtraTransform,
+                             const LayerSpace<SkIRect>& dstBounds) const {
+    // Tiling and color-filtering can completely fill 'fLayerBounds' in which case its edge is
+    // a transition from possibly non-transparent to definitely transparent color.
+    bool fillsLayerBounds = fills_layer_bounds(fColorFilter.get());
+    if (!fillsLayerBounds) {
+        // When that's not the case, 'fLayerBounds' may still be important if it crops the
+        // edges of the original transformed image itself.
+        LayerSpace<SkIRect> imageBounds = fTransform.mapRect(
+                    LayerSpace<SkIRect>{SkIRect::MakeWH(fImage->width(), fImage->height())});
+        fillsLayerBounds = !fLayerBounds.contains(imageBounds);
+    }
+
+    if (fillsLayerBounds) {
+        // Some content (either the image itself, or tiling/color-filtering) can produce
+        // non-transparent output beyond 'fLayerBounds'. 'fLayerBounds' can only be ignored if the
+        // desired output is completely contained within it (i.e. the edges of 'fLayerBounds' are
+        // not visible).
+        // NOTE: For the identity transform, this is equal to !fLayerBounds.contains(dstBounds)
+        return !SkRectPriv::QuadContainsRect(SkMatrix(xtraTransform),
+                                             SkIRect(fLayerBounds),
+                                             SkIRect(dstBounds));
+    } else {
+        // No part of the sampled and color-filtered image would produce non-transparent pixels
+        // outside of 'fLayerBounds' so 'fLayerBounds' can be ignored.
+        return false;
+    }
+}
+
 FilterResult FilterResult::applyCrop(const Context& ctx,
                                      const LayerSpace<SkIRect>& crop) const {
     LayerSpace<SkIRect> tightBounds = crop;
     // TODO(michaelludwig): Intersecting to the target output is only valid when the crop has
     // decal tiling (the only current option).
-    if (!fImage || !tightBounds.intersect(ctx.desiredOutput())) {
-        // The desired output would be filled with transparent black.
+    if (!fImage ||
+        !tightBounds.intersect(ctx.desiredOutput()) ||
+        !tightBounds.intersect(fLayerBounds)) {
+        // The desired output would be filled with transparent black. There should never be a
+        // color filter acting on an empty image that could change that assumption.
+        SkASSERT(fImage || !fColorFilter);
         return {};
     }
 
-    if (is_nearly_integer_translation(fTransform)) {
+    LayerSpace<SkIPoint> origin;
+    if (!fills_layer_bounds(fColorFilter.get()) &&
+         is_nearly_integer_translation(fTransform, &origin)) {
         // We can lift the crop to earlier in the order of operations and apply it to the image
-        // subset directly, which is handled inside this resolve() call.
-        return this->resolve(ctx, tightBounds);
+        // subset directly. This does not rely on resolve() to call extract_subset() because it
+        // will still render a new image if there's a color filter. As such, we have to preserve
+        // the current color filter on the new FilterResult.
+        // NOTE: Even though applying a crop never renders a new image, moving the crop into the
+        // image dimensions allows future operations like applying a transform or color filter to
+        // be composed without rendering a new image since there is no longer an intervening crop.
+        FilterResult restrictedOutput = extract_subset(fImage.get(), origin, tightBounds);
+        restrictedOutput.fColorFilter = fColorFilter;
+        return restrictedOutput;
     } else {
         // Otherwise cropping is the final operation to the FilterResult's image and can always be
         // applied by adjusting the layer bounds.
         FilterResult restrictedOutput = *this;
-        if (!restrictedOutput.fLayerBounds.intersect(tightBounds)) {
-            return {};
-        }
+        restrictedOutput.fLayerBounds = tightBounds;
         return restrictedOutput;
     }
 }
 
+FilterResult FilterResult::applyColorFilter(const Context& ctx,
+                                            sk_sp<SkColorFilter> colorFilter) const {
+    static const LayerSpace<SkMatrix> kIdentity{SkMatrix::I()};
+
+    // A null filter is the identity, so it should have been caught during image filter DAG creation
+    SkASSERT(colorFilter);
+
+    // Color filters are applied after the transform and image sampling, but before the fLayerBounds
+    // crop. We can compose 'colorFilter' with any previously applied color filter regardless
+    // of the transform/sample state, so long as it respects the effect of the current crop.
+    LayerSpace<SkIRect> newLayerBounds = fLayerBounds;
+    if (as_CFB(colorFilter)->affectsTransparentBlack()) {
+        if (!fImage || !newLayerBounds.intersect(ctx.desiredOutput())) {
+            // The current image's intersection with the desired output is fully transparent, but
+            // the new color filter converts that into a non-transparent color. The desired output
+            // is filled with this color.
+            // TODO: When kClamp is supported, we can allocate a smaller surface
+            sk_sp<SkSpecialSurface> surface = ctx.makeSurface(SkISize(ctx.desiredOutput().size()));
+            if (!surface) {
+                return {};
+            }
+
+            SkPaint paint;
+            paint.setColor4f(SkColors::kTransparent, /*colorSpace=*/nullptr);
+            paint.setColorFilter(std::move(colorFilter));
+            surface->getCanvas()->drawPaint(paint);
+            return {surface->makeImageSnapshot(), ctx.desiredOutput().topLeft()};
+        }
+
+        if (this->isCropped(kIdentity, ctx.desiredOutput())) {
+            // Since 'colorFilter' modifies transparent black, the new result's layer bounds must
+            // be the desired output. But if the current image is cropped we need to resolve the
+            // image to avoid losing the effect of the current 'fLayerBounds'.
+            FilterResult filtered = this->resolve(ctx, ctx.desiredOutput());
+            return filtered.applyColorFilter(ctx, std::move(colorFilter));
+        }
+
+        // otherwise we can fill out to the desired output without worrying about losing the crop.
+        newLayerBounds = ctx.desiredOutput();
+    } else {
+        if (!fImage || !newLayerBounds.intersect(ctx.desiredOutput())) {
+            // The color filter does not modify transparent black, so it remains transparent
+            return {};
+        }
+        // otherwise a non-transparent affecting color filter can always be lifted before any crop
+        // because it does not change the "shape" of the prior FilterResult.
+    }
+
+    // If we got here we can compose the new color filter with the previous filter and the prior
+    // layer bounds are either soft-cropped to the desired output, or we fill out the desired output
+    // when the new color filter affects transparent black. We don't check if the entire composed
+    // filter affects transparent black because earlier floods are restricted by the layer bounds.
+    FilterResult filtered = *this;
+    filtered.fLayerBounds = newLayerBounds;
+    filtered.fColorFilter = SkColorFilters::Compose(std::move(colorFilter), fColorFilter);
+    return filtered;
+}
+
 static bool compatible_sampling(const SkSamplingOptions& currentSampling,
                                 bool currentXformWontAffectNearest,
                                 SkSamplingOptions* nextSampling,
@@ -405,6 +535,7 @@
                                           const SkSamplingOptions &sampling) const {
     if (!fImage) {
         // Transformed transparent black remains transparent black.
+        SkASSERT(!fColorFilter);
         return {};
     }
 
@@ -420,18 +551,9 @@
 
     // Determine if the image is being visibly cropped by the layer bounds, in which case we can't
     // merge this transform with any previous transform (unless the new transform is an integer
-    // translation).
-    bool isCropped = false;
-    if (!nextXformIsInteger) {
-        LayerSpace<SkIRect> imageBounds = fTransform.mapRect(
-                    LayerSpace<SkIRect>{SkIRect::MakeWH(fImage->width(), fImage->height())});
-        if (!fLayerBounds.contains(imageBounds)) {
-            // Layer bounds restricts the mapped image, but it may not be visible.
-            isCropped = !SkRectPriv::QuadContainsRect(SkMatrix(transform),
-                                                      SkIRect(fLayerBounds),
-                                                      SkIRect(ctx.desiredOutput()));
-        }
-    }
+    // translation in which case any visible edge is aligned with the desired output and can be
+    // resolved by intersecting the transformed layer bounds and the output bounds).
+    bool isCropped = !nextXformIsInteger && this->isCropped(transform, ctx.desiredOutput());
 
     FilterResult transformed;
     if (!isCropped && compatible_sampling(fSamplingOptions, currentXformIsInteger,
@@ -472,37 +594,22 @@
 std::pair<sk_sp<SkSpecialImage>, LayerSpace<SkIPoint>> FilterResult::resolve(
         const Context& ctx,
         LayerSpace<SkIRect> dstBounds) const {
-    // TODO(michaelludwig): Only valid for kDecal, although kClamp would only need 1 extra
-    // pixel of padding so some restriction could happen. We also should skip the intersection if
-    // we need to include transparent black pixels.
+    // The layer bounds is the final clip, so it can always be used to restrict 'dstBounds'. Even
+    // if there's a non-decal tile mode or transparent-black affecting color filter, those floods
+    // are restricted to fLayerBounds.
     if (!fImage || !dstBounds.intersect(fLayerBounds)) {
         return {nullptr, {}};
     }
 
-    // TODO: This logic to skip a draw will also need to account for the tile mode, but we can
-    // always restrict to the intersection of dstBounds and the image's subset since we are
-    // currently always decal sampling.
+    // If we have any extra effect to apply, there's no point in trying to extract a subset.
+    // TODO: Also factor in a non-decal tile mode
+    const bool subsetCompatible = !fColorFilter;
+
     // TODO(michaelludwig): If we get to the point where all filter results track bounds in
     // floating point, then we can extend this case to any S+T transform.
     LayerSpace<SkIPoint> origin;
-    if (is_nearly_integer_translation(fTransform, &origin)) {
-        LayerSpace<SkIRect> imageBounds(SkIRect::MakeXYWH(origin.x(), origin.y(),
-                                                          fImage->width(), fImage->height()));
-        if (!imageBounds.intersect(dstBounds)) {
-            return {nullptr, {}};
-        }
-
-        // Offset the image subset directly to avoid issues negating (origin). With the prior
-        // intersection (bounds - origin) will be >= 0, but (bounds + (-origin)) may not, (e.g.
-        // origin is INT_MIN).
-        SkIRect subset = { imageBounds.left() - origin.x(),
-                           imageBounds.top() - origin.y(),
-                           imageBounds.right() - origin.x(),
-                           imageBounds.bottom() - origin.y() };
-        SkASSERT(subset.fLeft >= 0 && subset.fTop >= 0 &&
-                 subset.fRight <= fImage->width() && subset.fBottom <= fImage->height());
-
-        return {fImage->makeSubset(subset), imageBounds.topLeft()};
+    if (subsetCompatible && is_nearly_integer_translation(fTransform, &origin)) {
+        return extract_subset(fImage.get(), origin, dstBounds);
     } // else fall through and attempt a draw
 
     // Don't use context properties to avoid DMSAA on internal stages of filter evaluation.
@@ -511,6 +618,9 @@
     if (!surface) {
         return {nullptr, {}};
     }
+
+    // Since dstBounds has been intersected with fLayerBounds already, there is no need to
+    // explicitly clip the surface's canvas.
     SkCanvas* canvas = surface->getCanvas();
     // skbug.com/5075: GPU-backed special surfaces don't reset their contents.
     canvas->clear(SK_ColorTRANSPARENT);
@@ -519,18 +629,16 @@
     SkPaint paint;
     paint.setAntiAlias(true);
     paint.setBlendMode(SkBlendMode::kSrc);
+    paint.setColorFilter(fColorFilter);
 
-    // TODO: When using a tile mode other than kDecal, we'll need to use SkSpecialImage::asShader()
-    // and use drawRect(fLayerBounds).
-    if (!fLayerBounds.contains(dstBounds)) {
-        // We're resolving to a larger than necessary image, so make sure transparency outside of
-        // fLayerBounds is preserved.
-        // NOTE: This should only happen when the next layer requires processing transparent black.
-        canvas->clipIRect(SkIRect(fLayerBounds));
-    }
     canvas->concat(SkMatrix(fTransform)); // src's origin is embedded in fTransform
-    fImage->draw(canvas, 0.f, 0.f, fSamplingOptions, &paint);
 
+    if (fills_layer_bounds(fColorFilter.get())) {
+        paint.setShader(fImage->asShader(SkTileMode::kDecal, fSamplingOptions, SkMatrix::I()));
+        canvas->drawPaint(paint);
+    } else {
+        fImage->draw(canvas, 0.f, 0.f, fSamplingOptions, &paint);
+    }
     return {surface->makeImageSnapshot(), dstBounds.topLeft()};
 }
 
diff --git a/src/core/SkImageFilterTypes.h b/src/core/SkImageFilterTypes.h
index 8595820..184eaea 100644
--- a/src/core/SkImageFilterTypes.h
+++ b/src/core/SkImageFilterTypes.h
@@ -8,6 +8,7 @@
 #ifndef SkImageFilterTypes_DEFINED
 #define SkImageFilterTypes_DEFINED
 
+#include "include/core/SkColorFilter.h"
 #include "include/core/SkColorSpace.h"
 #include "include/core/SkMatrix.h"
 #include "include/core/SkPoint.h"
@@ -15,7 +16,6 @@
 #include "include/core/SkSamplingOptions.h"
 #include "include/core/SkTypes.h"
 #include "src/core/SkSpecialImage.h"
-#include "src/core/SkSpecialSurface.h"
 
 class GrRecordingContext;
 enum GrSurfaceOrigin : int;
@@ -635,6 +635,7 @@
             : fImage(std::move(image))
             , fSamplingOptions(kDefaultSampling)
             , fTransform(SkMatrix::Translate(origin.x(), origin.y()))
+            , fColorFilter(nullptr)
             , fLayerBounds(
                     fTransform.mapRect(LayerSpace<SkIRect>(fImage ? fImage->dimensions()
                                                                   : SkISize{0, 0}))) {}
@@ -653,13 +654,11 @@
     sk_sp<SkSpecialImage> refImage() const { return fImage; }
 
     // Get the layer-space bounds of the result. This will incorporate any layer-space transform.
-    LayerSpace<SkIRect> layerBounds() const {
-        return fLayerBounds;
-    }
+    LayerSpace<SkIRect> layerBounds() const { return fLayerBounds; }
 
-    SkSamplingOptions sampling() const {
-        return fSamplingOptions;
-    }
+    SkSamplingOptions sampling() const { return fSamplingOptions; }
+
+    const SkColorFilter* colorFilter() const { return fColorFilter.get(); }
 
     // Produce a new FilterResult that has been cropped to 'crop', taking into account the context's
     // desired output. When possible, the returned FilterResult will reuse the underlying image and
@@ -679,6 +678,12 @@
                                 const LayerSpace<SkMatrix>& transform,
                                 const SkSamplingOptions& sampling) const;
 
+    // Produce a new FilterResult that is visually equivalent to the output of the SkColorFilter
+    // evaluating this FilterResult. If the color filter affects transparent black, the returned
+    // FilterResult can become non-empty even if the input were empty.
+    FilterResult applyColorFilter(const Context& ctx,
+                                  sk_sp<SkColorFilter> colorFilter) const;
+
     // Extract image and origin, safely when the image is null. If there are deferred operations
     // on FilterResult (such as tiling or transforms) not representable as an image+origin pair,
     // the returned image will be the resolution resulting from that metadata and not necessarily
@@ -691,13 +696,19 @@
 
 private:
     // Renders this FilterResult into a new, but visually equivalent, image that fills 'dstBounds',
-    // has nearest-neighbor sampling, and a transform that just translates by 'dstBounds' TL corner.
+    // has default sampling, no color filter, and a transform that translates by only 'dstBounds's
+    // top-left corner. 'dstBounds' is always intersected with 'fLayerBounds'.
     std::pair<sk_sp<SkSpecialImage>, LayerSpace<SkIPoint>>
     resolve(const Context& ctx, LayerSpace<SkIRect> dstBounds) const;
 
+    // Returns true if the effects of the fLayerBounds crop are visible when this image is drawn
+    // with 'xtraTransform' restricted to 'dstBounds'.
+    bool isCropped(const LayerSpace<SkMatrix>& xtraTransform,
+                   const LayerSpace<SkIRect>& dstBounds) const;
+
     // The effective image of a FilterResult is 'fImage' sampled by 'fSamplingOptions' and
-    // respecting 'fTileMode' (on the SkSpecialImage's subset), transformed by 'fTransform', clipped
-    // to 'fLayerBounds'.
+    // respecting 'fTileMode' (on the SkSpecialImage's subset), transformed by 'fTransform',
+    // filtered by 'fColorFilter', and then clipped to 'fLayerBounds'.
     sk_sp<SkSpecialImage> fImage;
     SkSamplingOptions     fSamplingOptions;
     // SkTileMode         fTileMode = SkTileMode::kDecal;
@@ -705,6 +716,10 @@
     // but can become more complex when combined with applyTransform().
     LayerSpace<SkMatrix>  fTransform;
 
+    // A null color filter is the identity function. Since the output is clipped to fLayerBounds
+    // after color filtering, SkColorFilters that affect transparent black are not unbounded.
+    sk_sp<SkColorFilter>  fColorFilter;
+
     // The layer bounds are initially fImage's dimensions mapped by fTransform. As the filter result
     // is processed by the image filter DAG, it can be further restricted by crop rects or the
     // implicit desired output at each node.
diff --git a/tests/FilterResultTest.cpp b/tests/FilterResultTest.cpp
index 20ca01e..d6ac61b 100644
--- a/tests/FilterResultTest.cpp
+++ b/tests/FilterResultTest.cpp
@@ -11,7 +11,10 @@
 #include "include/core/SkCanvas.h"
 #include "include/core/SkClipOp.h"
 #include "include/core/SkColor.h"
+#include "include/core/SkColorFilter.h"
+#include "include/core/SkColorSpace.h"
 #include "include/core/SkColorType.h"
+#include "include/core/SkData.h"
 #include "include/core/SkImage.h"
 #include "include/core/SkImageInfo.h"
 #include "include/core/SkMatrix.h"
@@ -29,7 +32,9 @@
 #include "include/private/base/SkDebug.h"
 #include "include/private/base/SkTArray.h"
 #include "include/private/base/SkTo.h"
+#include "src/core/SkColorFilterBase.h"
 #include "src/core/SkImageFilterTypes.h"
+#include "src/core/SkRectPriv.h"
 #include "src/core/SkSpecialImage.h"
 #include "src/core/SkSpecialSurface.h"
 #include "tests/CtsEnforcement.h"
@@ -78,6 +83,16 @@
 static_assert(std::size(kFuzzyKernel) == std::size(kFuzzyKernel[0]), "Kernel must be square");
 static constexpr int kKernelSize = std::size(kFuzzyKernel);
 
+bool colorfilter_equals(const SkColorFilter* actual, const SkColorFilter* expected) {
+    if (!actual || !expected) {
+        return !actual && !expected; // both null
+    }
+    // The two filter objects are equal if they serialize to the same structure
+    sk_sp<SkData> actualData = actual->serialize();
+    sk_sp<SkData> expectedData = expected->serialize();
+    return actualData && actualData->equals(expectedData.get());
+}
+
 enum class Expect {
     kDeferredImage, // i.e. modified properties of FilterResult instead of rendering
     kNewImage,      // i.e. rendered a new image before modifying other properties
@@ -98,17 +113,30 @@
     ApplyAction(const SkMatrix& transform,
                 const SkSamplingOptions& sampling,
                 Expect expectation,
-                const SkSamplingOptions& expectedSampling)
+                const SkSamplingOptions& expectedSampling,
+                sk_sp<SkColorFilter> expectedColorFilter)
             : fAction{TransformParams{LayerSpace<SkMatrix>(transform), sampling}}
             , fExpectation(expectation)
-            , fExpectedSampling(expectedSampling) {}
+            , fExpectedSampling(expectedSampling)
+            , fExpectedColorFilter(std::move(expectedColorFilter)) {}
 
     ApplyAction(const SkIRect& cropRect,
                 Expect expectation,
-                const SkSamplingOptions& expectedSampling)
+                const SkSamplingOptions& expectedSampling,
+                sk_sp<SkColorFilter> expectedColorFilter)
             : fAction{CropParams{LayerSpace<SkIRect>(cropRect)}}
             , fExpectation(expectation)
-            , fExpectedSampling(expectedSampling) {}
+            , fExpectedSampling(expectedSampling)
+            , fExpectedColorFilter(std::move(expectedColorFilter)) {}
+
+    ApplyAction(sk_sp<SkColorFilter> colorFilter,
+                Expect expectation,
+                const SkSamplingOptions& expectedSampling,
+                sk_sp<SkColorFilter> expectedColorFilter)
+            : fAction(std::move(colorFilter))
+            , fExpectation(expectation)
+            , fExpectedSampling(expectedSampling)
+            , fExpectedColorFilter(std::move(expectedColorFilter)) {}
 
     // Test-simplified logic for bounds propagation similar to how image filters calculate bounds
     // while evaluating a filter DAG, which is outside of skif::FilterResult's responsibilities.
@@ -123,6 +151,8 @@
                 intersection = LayerSpace<SkIRect>::Empty();
             }
             return intersection;
+        } else if (std::holds_alternative<sk_sp<SkColorFilter>>(fAction)) {
+            return desiredOutput;
         }
         SkUNREACHABLE;
     }
@@ -133,12 +163,15 @@
             return in.applyTransform(ctx, t->fMatrix, t->fSampling);
         } else if (auto* c = std::get_if<CropParams>(&fAction)) {
             return in.applyCrop(ctx, c->fRect);
+        } else if (auto* cf = std::get_if<sk_sp<SkColorFilter>>(&fAction)) {
+            return in.applyColorFilter(ctx, *cf);
         }
         SkUNREACHABLE;
     }
 
     Expect expectation() const { return fExpectation; }
     const SkSamplingOptions& expectedSampling() const { return fExpectedSampling; }
+    const SkColorFilter* expectedColorFilter() const { return fExpectedColorFilter.get(); }
 
     LayerSpace<SkIRect> expectedBounds(const LayerSpace<SkIRect>& inputBounds) const {
         // This assumes anything outside 'inputBounds' is transparent black.
@@ -153,6 +186,13 @@
                 intersection = LayerSpace<SkIRect>::Empty();
             }
             return intersection;
+        } else if (auto* cf = std::get_if<sk_sp<SkColorFilter>>(&fAction)) {
+            if (as_CFB(*cf)->affectsTransparentBlack()) {
+                // Fills out infinitely
+                return LayerSpace<SkIRect>(SkRectPriv::MakeILarge());
+            } else {
+                return inputBounds;
+            }
         }
         SkUNREACHABLE;
     }
@@ -197,6 +237,8 @@
                 canvas->concat(m);
             } else if (auto* c = std::get_if<CropParams>(&fAction)) {
                 canvas->clipIRect(SkIRect(c->fRect));
+            } else if (auto* cf = std::get_if<sk_sp<SkColorFilter>>(&fAction)) {
+                paint.setColorFilter(*cf);
             }
             paint.setShader(source->asShader(SkTileMode::kDecal,
                                              sampling,
@@ -208,14 +250,15 @@
 
 private:
     // Action
-    std::variant<TransformParams, // for applyTransform()
-                CropParams        // for applyCrop()
-                // TODO: add variants for SkColorFilters, etc.
-            > fAction;
+    std::variant<TransformParams,     // for applyTransform()
+                 CropParams,          // for applyCrop()
+                 sk_sp<SkColorFilter> // for applyColorFilter()
+                > fAction;
 
     // Expectation
     Expect fExpectation;
     SkSamplingOptions fExpectedSampling;
+    sk_sp<SkColorFilter> fExpectedColorFilter;
     // The expected desired outputs and layer bounds are calculated automatically based on the
     // action type and parameters to simplify test case specification.
 };
@@ -535,7 +578,8 @@
     TestCase& applyCrop(const SkIRect& crop,
                         Expect expectation) {
         fActions.emplace_back(crop, expectation,
-                              this->getDefaultExpectedSampling(expectation));
+                              this->getDefaultExpectedSampling(expectation),
+                              this->getDefaultExpectedColorFilter(expectation));
         return *this;
     }
 
@@ -552,11 +596,26 @@
         if (!expectedSampling.has_value()) {
             expectedSampling = sampling;
         }
-        fActions.emplace_back(matrix, sampling, expectation, *expectedSampling);
+        fActions.emplace_back(matrix, sampling, expectation, *expectedSampling,
+                              this->getDefaultExpectedColorFilter(expectation));
         return *this;
     }
 
-    // TODO: applyColorFilter() etc. to maintain parity with FilterResult API
+    TestCase& applyColorFilter(sk_sp<SkColorFilter> colorFilter,
+                               Expect expectation,
+                               std::optional<sk_sp<SkColorFilter>> expectedColorFilter = {}) {
+        // The expected color filter is the composition of the default expectation (e.g. last
+        // color filter or null for a new image) and the new 'colorFilter'. Compose() automatically
+        // returns 'colorFilter' if the inner filter is null.
+        if (!expectedColorFilter.has_value()) {
+            expectedColorFilter = SkColorFilters::Compose(
+                    colorFilter, this->getDefaultExpectedColorFilter(expectation));
+        }
+        fActions.emplace_back(std::move(colorFilter), expectation,
+                              this->getDefaultExpectedSampling(expectation),
+                              std::move(*expectedColorFilter));
+        return *this;
+    }
 
     void run(const SkIRect& requestedOutput) const {
         skiatest::ReporterContext caseLabel(fRunner, fName);
@@ -573,20 +632,28 @@
         auto desiredOutput = LayerSpace<SkIRect>(requestedOutput);
         std::vector<LayerSpace<SkIRect>> desiredOutputs;
         desiredOutputs.resize(fActions.size(), desiredOutput);
-        if (backPropagateDesiredOutput) {
-            // Every action has its own desired output, but they are calculated by propagating the
-            // root bounds from the last action to the first.
-            for (int i = (int) fActions.size() - 2; i >= 0; --i) {
-                desiredOutputs[i] = fActions[i+1].requiredInput(desiredOutputs[i+1]);
-            }
-        } else {
+        if (!backPropagateDesiredOutput) {
             // Set the desired output to be equal to the expected output so that there is no
             // further restriction of what's computed for early actions to then be ruled out by
             // subsequent actions.
             auto inputBounds = fSourceBounds;
             for (int i = 0; i < (int) fActions.size() - 1; ++i) {
-                inputBounds = fActions[i].expectedBounds(inputBounds);
-                desiredOutputs[i] = inputBounds;
+                desiredOutputs[i] = fActions[i].expectedBounds(inputBounds);
+                // If the output for the ith action is infinite, leave it for now and expand the
+                // input bounds for action i+1. The infinite bounds will be replaced by the
+                // back-propagated desired output of the next action.
+                if (SkIRect(desiredOutputs[i]) == SkRectPriv::MakeILarge()) {
+                    inputBounds.outset(LayerSpace<SkISize>({25, 25}));
+                } else {
+                    inputBounds = desiredOutputs[i];
+                }
+            }
+        }
+        // Fill out regular back-propagated desired outputs and cleanup infinite outputs
+        for (int i = (int) fActions.size() - 2; i >= 0; --i) {
+            if (backPropagateDesiredOutput ||
+                SkIRect(desiredOutputs[i]) == SkRectPriv::MakeILarge()) {
+                desiredOutputs[i] = fActions[i+1].requiredInput(desiredOutputs[i+1]);
             }
         }
 
@@ -633,6 +700,10 @@
                                          backPropagateDesiredOutput);
                 expectedBounds = LayerSpace<SkIRect>::Empty();
                 correctedExpectation = Expect::kEmptyImage;
+            } else if (SkIRect(expectedBounds) == SkRectPriv::MakeILarge()) {
+                // An expected image filling out to infinity should have an actual image that
+                // fills the desired output.
+                expectedBounds = desiredOutputs[i];
             }
 
             bool actualNewImage = output.image() &&
@@ -654,6 +725,8 @@
                 REPORTER_ASSERT(fRunner, !expectedBounds.isEmpty());
                 REPORTER_ASSERT(fRunner, SkIRect(output.layerBounds()) == SkIRect(expectedBounds));
                 REPORTER_ASSERT(fRunner, output.sampling() == fActions[i].expectedSampling());
+                REPORTER_ASSERT(fRunner, colorfilter_equals(output.colorFilter(),
+                                                            fActions[i].expectedColorFilter()));
             }
 
             expectedImage = fActions[i].renderExpectedImage(ctx,
@@ -683,6 +756,15 @@
             return fActions[fActions.size() - 1].expectedSampling();
         }
     }
+    // By default an action that doesn't define its own color filter will not change filtering,
+    // unless it produces a new image. Otherwise it inherits the prior action's expectations.
+    sk_sp<SkColorFilter> getDefaultExpectedColorFilter(Expect expectation) const {
+        if (expectation != Expect::kDeferredImage || fActions.empty()) {
+            return nullptr;
+        } else {
+            return sk_ref_sp(fActions[fActions.size() - 1].expectedColorFilter());
+        }
+    }
 
     TestRunner& fRunner;
     std::string fName;
@@ -697,6 +779,24 @@
     std::vector<ApplyAction> fActions;
 };
 
+// ----------------------------------------------------------------------------
+// Utilities to create color filters for the unit tests
+
+sk_sp<SkColorFilter> alpha_modulate(float v) {
+    // dst-in blending with src = (1,1,1,v) = dst * v
+    auto cf = SkColorFilters::Blend({1.f,1.f,1.f,v}, /*colorSpace=*/nullptr, SkBlendMode::kDstIn);
+    SkASSERT(cf && !as_CFB(cf)->affectsTransparentBlack());
+    return cf;
+}
+
+sk_sp<SkColorFilter> affect_transparent(SkColor4f color) {
+    auto cf = SkColorFilters::Blend(color, /*colorSpace=*/nullptr, SkBlendMode::kPlus);
+    SkASSERT(cf && as_CFB(cf)->affectsTransparentBlack());
+    return cf;
+}
+
+// ----------------------------------------------------------------------------
+
 #if defined(SK_GANESH)
 #define DEF_GANESH_TEST_SUITE(name) \
     DEF_GANESH_TEST_FOR_RENDERING_CONTEXTS( \
@@ -747,7 +847,8 @@
 
 DEF_TEST_SUITE(EmptySource, r) {
     // This is testing that an empty input image is handled by the applied actions without having
-    // to generate new images.
+    // to generate new images, or that it can produce a new image from nothing when it affects
+    // transparent black.
     TestCase(r, "applyCrop() to empty source")
             .source(SkIRect::MakeEmpty(), SkColors::kRed)
             .applyCrop({0, 0, 10, 10}, Expect::kEmptyImage)
@@ -757,6 +858,17 @@
             .source(SkIRect::MakeEmpty(), SkColors::kRed)
             .applyTransform(SkMatrix::Translate(10.f, 10.f), Expect::kEmptyImage)
             .run(/*requestedOutput=*/{10, 10, 20, 20});
+
+    TestCase(r, "applyColorFilter() to empty source")
+            .source(SkIRect::MakeEmpty(), SkColors::kRed)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kEmptyImage)
+            .run(/*requestedOutput=*/{0, 0, 10, 10});
+
+    TestCase(r, "Transparency-affecting color filter overrules empty source")
+            .source(SkIRect::MakeEmpty(), SkColors::kRed)
+            .applyColorFilter(affect_transparent(SkColors::kBlue), Expect::kNewImage,
+                              /*expectedColorFilter=*/nullptr) // CF applied ASAP to make a new img
+            .run(/*requestedOutput=*/{0, 0, 10, 10});
 }
 
 DEF_TEST_SUITE(EmptyDesiredOutput, r) {
@@ -771,6 +883,16 @@
             .source({0, 0, 10, 10}, SkColors::kRed)
             .applyTransform(SkMatrix::RotateDeg(10.f), Expect::kEmptyImage)
             .run(/*requestedOutput=*/SkIRect::MakeEmpty());
+
+    TestCase(r, "applyColorFilter() + empty output becomes empty")
+            .source({0, 0, 10, 10}, SkColors::kRed)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kEmptyImage)
+            .run(/*requestedOutput=*/SkIRect::MakeEmpty());
+
+    TestCase(r, "Transpency-affecting color filter + empty output is empty")
+            .source({0, 0, 10, 10}, SkColors::kRed)
+            .applyColorFilter(affect_transparent(SkColors::kBlue), Expect::kEmptyImage)
+            .run(/*requestedOutput=*/SkIRect::MakeEmpty());
 }
 
 // ----------------------------------------------------------------------------
@@ -1115,6 +1237,324 @@
             .run(/*requestedOutput=*/{0, 0, 64, 64});
 }
 
+// ----------------------------------------------------------------------------
+// applyColorFilter() and interactions with transforms/crops
 
+DEF_TEST_SUITE(ColorFilter, r) {
+    TestCase(r, "applyColorFilter() defers image")
+            .source({0, 0, 24, 24}, SkColors::kGreen)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    TestCase(r, "applyColorFilter() composes with other color filters")
+            .source({0, 0, 24, 24}, SkColors::kGreen)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kDeferredImage)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    TestCase(r, "Transparency-affecting color filter fills output")
+            .source({0, 0, 24, 24}, SkColors::kGreen)
+            .applyColorFilter(affect_transparent(SkColors::kBlue), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{-8, -8, 32, 32});
+
+    // Since there is no cropping between the composed color filters, transparency-affecting CFs
+    // can still compose together.
+    TestCase(r, "Transparency-affecting composition fills output (ATBx2)")
+            .source({0, 0, 24, 24}, SkColors::kGreen)
+            .applyColorFilter(affect_transparent(SkColors::kBlue), Expect::kDeferredImage)
+            .applyColorFilter(affect_transparent(SkColors::kRed), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{-8, -8, 32, 32});
+
+    TestCase(r, "Transparency-affecting composition fills output (ATB,reg)")
+            .source({0, 0, 24, 24}, SkColors::kGreen)
+            .applyColorFilter(affect_transparent(SkColors::kBlue), Expect::kDeferredImage)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{-8, -8, 32, 32});
+
+    TestCase(r, "Transparency-affecting composition fills output (reg,ATB)")
+            .source({0, 0, 24, 24}, SkColors::kGreen)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kDeferredImage)
+            .applyColorFilter(affect_transparent(SkColors::kBlue), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{-8, -8, 32, 32});
+}
+
+DEF_TEST_SUITE(TransformedColorFilter, r) {
+    TestCase(r, "Transform composes with regular CF")
+            .source({0, 0, 24, 24}, SkColors::kRed)
+            .applyTransform(SkMatrix::RotateDeg(45.f, {12, 12}), Expect::kDeferredImage)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 24, 24});
+
+    TestCase(r, "Regular CF composes with transform")
+            .source({0, 0, 24, 24}, SkColors::kRed)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kDeferredImage)
+            .applyTransform(SkMatrix::RotateDeg(45.f, {12, 12}), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 24, 24});
+
+    TestCase(r, "Transform composes with transparency-affecting CF")
+            .source({0, 0, 24, 24}, SkColors::kRed)
+            .applyTransform(SkMatrix::RotateDeg(45.f, {12, 12}), Expect::kDeferredImage)
+            .applyColorFilter(affect_transparent(SkColors::kBlue), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 24, 24});
+
+    // NOTE: Because there is no explicit crop between the color filter and the transform,
+    // output bounds propagation means the layer bounds of the applied color filter are never
+    // visible post transform. This is detected and allows the transform to be composed without
+    // producing an intermediate image. See later tests for when a crop prevents this optimization.
+    TestCase(r, "Transparency-affecting CF composes with transform")
+            .source({0, 0, 24, 24}, SkColors::kRed)
+            .applyColorFilter(affect_transparent(SkColors::kBlue), Expect::kDeferredImage)
+            .applyTransform(SkMatrix::RotateDeg(45.f, {12, 12}), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{-50, -50, 50, 50});
+}
+
+DEF_TEST_SUITE(TransformBetweenColorFilters, r) {
+    // NOTE: The lack of explicit crops allows all of these operations to be optimized as well.
+    TestCase(r, "Transform between regular color filters")
+            .source({0, 0, 24, 24}, SkColors::kRed)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kDeferredImage)
+            .applyTransform(SkMatrix::RotateDeg(45.f, {12, 12}), Expect::kDeferredImage)
+            .applyColorFilter(alpha_modulate(0.75f), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 24, 24});
+
+    TestCase(r, "Transform between transparency-affecting color filters")
+            .source({0, 0, 24, 24}, SkColors::kRed)
+            .applyColorFilter(affect_transparent(SkColors::kBlue), Expect::kDeferredImage)
+            .applyTransform(SkMatrix::RotateDeg(45.f, {12, 12}), Expect::kDeferredImage)
+            .applyColorFilter(affect_transparent(SkColors::kGreen), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 24, 24});
+
+    TestCase(r, "Transform between ATB and regular color filters")
+            .source({0, 0, 24, 24}, SkColors::kRed)
+            .applyColorFilter(affect_transparent(SkColors::kBlue), Expect::kDeferredImage)
+            .applyTransform(SkMatrix::RotateDeg(45.f, {12, 12}), Expect::kDeferredImage)
+            .applyColorFilter(alpha_modulate(0.75f), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 24, 24});
+
+    TestCase(r, "Transform between regular and ATB color filters")
+            .source({0, 0, 24, 24}, SkColors::kRed)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kDeferredImage)
+            .applyTransform(SkMatrix::RotateDeg(45.f, {12, 12}), Expect::kDeferredImage)
+            .applyColorFilter(affect_transparent(SkColors::kGreen), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 24, 24});
+}
+
+DEF_TEST_SUITE(ColorFilterBetweenTransforms, r) {
+    TestCase(r, "Regular color filter between transforms")
+            .source({0, 0, 24, 24}, SkColors::kGreen)
+            .applyTransform(SkMatrix::RotateDeg(20.f, {12, 12}), Expect::kDeferredImage)
+            .applyColorFilter(alpha_modulate(0.8f), Expect::kDeferredImage)
+            .applyTransform(SkMatrix::RotateDeg(10.f, {5.f, 8.f}), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 24, 24});
+
+    TestCase(r, "Transparency-affecting color filter between transforms")
+            .source({0, 0, 24, 24}, SkColors::kGreen)
+            .applyTransform(SkMatrix::RotateDeg(20.f, {12, 12}), Expect::kDeferredImage)
+            .applyColorFilter(affect_transparent(SkColors::kRed), Expect::kDeferredImage)
+            .applyTransform(SkMatrix::RotateDeg(10.f, {5.f, 8.f}), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 24, 24});
+}
+
+DEF_TEST_SUITE(CroppedColorFilter, r) {
+    TestCase(r, "Regular color filter after empty crop stays empty")
+            .source({0, 0, 16, 16}, SkColors::kBlue)
+            .applyCrop(SkIRect::MakeEmpty(), Expect::kEmptyImage)
+            .applyColorFilter(alpha_modulate(0.2f), Expect::kEmptyImage)
+            .run(/*requestedOutput=*/{0, 0, 16, 16});
+
+    TestCase(r, "Transparency-affecting color filter after empty crop creates new image")
+            .source({0, 0, 16, 16}, SkColors::kBlue)
+            .applyCrop(SkIRect::MakeEmpty(), Expect::kEmptyImage)
+            .applyColorFilter(affect_transparent(SkColors::kRed), Expect::kNewImage,
+                              /*expectedColorFilter=*/nullptr) // CF applied ASAP to make a new img
+            .run(/*requestedOutput=*/{0, 0, 16, 16});
+
+    TestCase(r, "Regular color filter composes with crop")
+            .source({0, 0, 32, 32}, SkColors::kBlue)
+            .applyColorFilter(alpha_modulate(0.7f), Expect::kDeferredImage)
+            .applyCrop({8, 8, 24, 24}, Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    TestCase(r, "Crop composes with regular color filter")
+            .source({0, 0, 32, 32}, SkColors::kBlue)
+            .applyCrop({8, 8, 24, 24}, Expect::kDeferredImage)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    TestCase(r, "Transparency-affecting color filter restricted by crop")
+            .source({0, 0, 32, 32}, SkColors::kBlue)
+            .applyColorFilter(affect_transparent(SkColors::kRed), Expect::kDeferredImage)
+            .applyCrop({8, 8, 24, 24}, Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    TestCase(r, "Crop composes with transparency-affecting color filter")
+            .source({0, 0, 32, 32}, SkColors::kBlue)
+            .applyCrop({8, 8, 24, 24}, Expect::kDeferredImage)
+            .applyColorFilter(affect_transparent(SkColors::kRed), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+}
+
+DEF_TEST_SUITE(CropBetweenColorFilters, r) {
+    TestCase(r, "Crop between regular color filters")
+            .source({0, 0, 32, 32}, SkColors::kBlue)
+            .applyColorFilter(alpha_modulate(0.8f), Expect::kDeferredImage)
+            .applyCrop({8, 8, 24, 24}, Expect::kDeferredImage)
+            .applyColorFilter(alpha_modulate(0.4f), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    TestCase(r, "Crop between transparency-affecting color filters requires new image")
+            .source({0, 0, 32, 32}, SkColors::kBlue)
+            .applyColorFilter(affect_transparent(SkColors::kGreen), Expect::kDeferredImage)
+            .applyCrop({8, 8, 24, 24}, Expect::kDeferredImage)
+            .applyColorFilter(affect_transparent(SkColors::kRed), Expect::kNewImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    TestCase(r, "Output-constrained crop between transparency-affecting color filters does not")
+            .source({0, 0, 32, 32}, SkColors::kBlue)
+            .applyColorFilter(affect_transparent(SkColors::kGreen), Expect::kDeferredImage)
+            .applyCrop({8, 8, 24, 24}, Expect::kDeferredImage)
+            .applyColorFilter(affect_transparent(SkColors::kRed), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{8, 8, 24, 24});
+
+    TestCase(r, "Crop between regular and ATB color filters")
+            .source({0, 0, 32, 32}, SkColors::kBlue)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kDeferredImage)
+            .applyCrop({8, 8, 24, 24}, Expect::kDeferredImage)
+            .applyColorFilter(affect_transparent(SkColors::kRed), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    TestCase(r, "Crop between ATB and regular color filters")
+            .source({0, 0, 32, 32}, SkColors::kBlue)
+            .applyColorFilter(affect_transparent(SkColors::kRed), Expect::kDeferredImage)
+            .applyCrop({8, 8, 24, 24}, Expect::kDeferredImage)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+}
+
+DEF_TEST_SUITE(ColorFilterBetweenCrops, r) {
+    TestCase(r, "Regular color filter between crops")
+            .source({0, 0, 32, 32}, SkColors::kBlue)
+            .applyCrop({4, 4, 24, 24}, Expect::kDeferredImage)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kDeferredImage)
+            .applyCrop({15, 15, 32, 32}, Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    TestCase(r, "Transparency-affecting color filter between crops")
+            .source({0, 0, 32, 32}, SkColors::kBlue)
+            .applyCrop({4, 4, 24, 24}, Expect::kDeferredImage)
+            .applyColorFilter(affect_transparent(SkColors::kGreen), Expect::kDeferredImage)
+            .applyCrop({15, 15, 32, 32}, Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+}
+
+DEF_TEST_SUITE(CroppedTransformedColorFilter, r) {
+    TestCase(r, "Transform -> crop -> regular color filter")
+            .source({0, 0, 32, 32}, SkColors::kRed)
+            .applyTransform(SkMatrix::RotateDeg(30.f, {16, 16}), Expect::kDeferredImage)
+            .applyCrop({2, 2, 30, 30}, Expect::kDeferredImage)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    TestCase(r, "Transform -> regular color filter -> crop")
+            .source({0, 0, 32, 32}, SkColors::kRed)
+            .applyTransform(SkMatrix::RotateDeg(30.f, {16, 16}), Expect::kDeferredImage)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kDeferredImage)
+            .applyCrop({2, 2, 30, 30}, Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    TestCase(r, "Crop -> transform -> regular color filter")
+            .source({0, 0, 32, 32}, SkColors::kRed)
+            .applyCrop({2, 2, 30, 30}, Expect::kDeferredImage)
+            .applyTransform(SkMatrix::RotateDeg(30.f, {16, 16}), Expect::kDeferredImage)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    TestCase(r, "Crop -> regular color filter -> transform")
+            .source({0, 0, 32, 32}, SkColors::kRed)
+            .applyCrop({2, 2, 30, 30}, Expect::kDeferredImage)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kDeferredImage)
+            .applyTransform(SkMatrix::RotateDeg(30.f, {16, 16}), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    TestCase(r, "Regular color filter -> transform -> crop")
+            .source({0, 0, 32, 32}, SkColors::kRed)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kDeferredImage)
+            .applyTransform(SkMatrix::RotateDeg(30.f, {16, 16}), Expect::kDeferredImage)
+            .applyCrop({2, 2, 30, 30}, Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    TestCase(r, "Regular color filter -> crop -> transform")
+            .source({0, 0, 32, 32}, SkColors::kRed)
+            .applyColorFilter(alpha_modulate(0.5f), Expect::kDeferredImage)
+            .applyCrop({2, 2, 30, 30}, Expect::kDeferredImage)
+            .applyTransform(SkMatrix::RotateDeg(30.f, {16, 16}), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+}
+
+DEF_TEST_SUITE(CroppedTransformedTransparencyAffectingColorFilter, r) {
+    // When the crop is not between the transform and transparency-affecting color filter,
+    // either the order of operations or the bounds propagation means that every action can be
+    // deferred. Below, when the crop is between the two actions, new images are triggered.
+    TestCase(r, "Transform -> transparency-affecting color filter -> crop")
+            .source({0, 0, 32, 32}, SkColors::kRed)
+            .applyTransform(SkMatrix::RotateDeg(30.f, {16, 16}), Expect::kDeferredImage)
+            .applyColorFilter(affect_transparent(SkColors::kGreen), Expect::kDeferredImage)
+            .applyCrop({2, 2, 30, 30}, Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    TestCase(r, "Crop -> transform -> transparency-affecting color filter")
+            .source({0, 0, 32, 32}, SkColors::kRed)
+            .applyCrop({2, 2, 30, 30}, Expect::kDeferredImage)
+            .applyTransform(SkMatrix::RotateDeg(30.f, {16, 16}), Expect::kDeferredImage)
+            .applyColorFilter(affect_transparent(SkColors::kGreen), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    TestCase(r, "Crop -> transparency-affecting color filter -> transform")
+            .source({0, 0, 32, 32}, SkColors::kRed)
+            .applyCrop({2, 2, 30, 30}, Expect::kDeferredImage)
+            .applyColorFilter(affect_transparent(SkColors::kGreen), Expect::kDeferredImage)
+            .applyTransform(SkMatrix::RotateDeg(30.f, {16, 16}), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    TestCase(r, "Transparency-affecting color filter -> transform -> crop")
+            .source({0, 0, 32, 32}, SkColors::kRed)
+            .applyColorFilter(affect_transparent(SkColors::kGreen), Expect::kDeferredImage)
+            .applyTransform(SkMatrix::RotateDeg(30.f, {16, 16}), Expect::kDeferredImage)
+            .applyCrop({2, 2, 30, 30}, Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    // Since the crop is between the transform and color filter (or vice versa), transparency
+    // outside the crop is introduced that should not be affected by the color filter were no
+    // new image to be created.
+    TestCase(r, "Transform -> crop -> transparency-affecting color filter")
+            .source({0, 0, 32, 32}, SkColors::kRed)
+            .applyTransform(SkMatrix::RotateDeg(30.f, {16, 16}), Expect::kDeferredImage)
+            .applyCrop({2, 2, 30, 30}, Expect::kDeferredImage)
+            .applyColorFilter(affect_transparent(SkColors::kGreen), Expect::kNewImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    TestCase(r, "Transparency-affecting color filter -> crop -> transform")
+            .source({0, 0, 32, 32}, SkColors::kRed)
+            .applyColorFilter(affect_transparent(SkColors::kGreen), Expect::kDeferredImage)
+            .applyCrop({2, 2, 30, 30}, Expect::kDeferredImage)
+            .applyTransform(SkMatrix::RotateDeg(30.f, {16, 16}), Expect::kNewImage)
+            .run(/*requestedOutput=*/{0, 0, 32, 32});
+
+    // However if the output is small enough to fit within the transformed interior, the
+    // transparency is not visible.
+    TestCase(r, "Transform -> crop -> transparency-affecting color filter")
+            .source({0, 0, 32, 32}, SkColors::kRed)
+            .applyTransform(SkMatrix::RotateDeg(30.f, {16, 16}), Expect::kDeferredImage)
+            .applyCrop({2, 2, 30, 30}, Expect::kDeferredImage)
+            .applyColorFilter(affect_transparent(SkColors::kGreen), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{15, 15, 21, 21});
+
+    TestCase(r, "Transparency-affecting color filter -> crop -> transform")
+            .source({0, 0, 32, 32}, SkColors::kRed)
+            .applyColorFilter(affect_transparent(SkColors::kGreen), Expect::kDeferredImage)
+            .applyCrop({2, 2, 30, 30}, Expect::kDeferredImage)
+            .applyTransform(SkMatrix::RotateDeg(30.f, {16, 16}), Expect::kDeferredImage)
+            .run(/*requestedOutput=*/{15, 15, 21, 21});
+}
 
 } // anonymous namespace