[skottie] Split-up Skottie.cpp

Introduce more granular compilation units for major Skottie layer types.

TBR=
Change-Id: Iee2ef05cbcdda06467674824eb295ae6d7dc5bb9
Reviewed-on: https://skia-review.googlesource.com/148394
Commit-Queue: Florin Malita <fmalita@chromium.org>
Reviewed-by: Florin Malita <fmalita@chromium.org>
diff --git a/modules/skottie/skottie.gni b/modules/skottie/skottie.gni
index 284cec8..eb85a4a 100644
--- a/modules/skottie/skottie.gni
+++ b/modules/skottie/skottie.gni
@@ -17,7 +17,11 @@
   "$_src/SkottieAnimator.h",
   "$_src/SkottieJson.cpp",
   "$_src/SkottieJson.h",
+  "$_src/SkottieLayer.cpp",
+  "$_src/SkottieLayerEffect.cpp",
   "$_src/SkottiePriv.h",
+  "$_src/SkottiePrecompLayer.cpp",
+  "$_src/SkottieShapeLayer.cpp",
   "$_src/SkottieTextLayer.cpp",
   "$_src/SkottieValue.cpp",
   "$_src/SkottieValue.h",
diff --git a/modules/skottie/src/Skottie.cpp b/modules/skottie/src/Skottie.cpp
index 1c070f4..da1f835 100644
--- a/modules/skottie/src/Skottie.cpp
+++ b/modules/skottie/src/Skottie.cpp
@@ -10,34 +10,18 @@
 #include "SkCanvas.h"
 #include "SkData.h"
 #include "SkFontMgr.h"
-#include "SkImage.h"
 #include "SkMakeUnique.h"
 #include "SkOSPath.h"
 #include "SkPaint.h"
-#include "SkParse.h"
 #include "SkPoint.h"
-#include "SkSGClipEffect.h"
 #include "SkSGColor.h"
-#include "SkSGColorFilter.h"
-#include "SkSGDraw.h"
-#include "SkSGGeometryTransform.h"
-#include "SkSGGradient.h"
-#include "SkSGGroup.h"
-#include "SkSGImage.h"
 #include "SkSGInvalidationController.h"
-#include "SkSGMaskEffect.h"
-#include "SkSGMerge.h"
 #include "SkSGOpacityEffect.h"
 #include "SkSGPath.h"
-#include "SkSGRect.h"
-#include "SkSGRoundEffect.h"
 #include "SkSGScene.h"
 #include "SkSGTransform.h"
-#include "SkSGTrimEffect.h"
 #include "SkStream.h"
 #include "SkTArray.h"
-#include "SkTHash.h"
-#include "SkTLazy.h"
 #include "SkTime.h"
 #include "SkTo.h"
 #include "SkottieAdapter.h"
@@ -47,7 +31,6 @@
 #include "SkottieValue.h"
 
 #include <cmath>
-#include <vector>
 
 #include "stdlib.h"
 
@@ -60,15 +43,6 @@
     LOG("%s: %s\n", msg, dump.c_str());
 }
 
-namespace {
-
-// DEPRECATED: replace w/ LogJSON.
-bool LogFail(const skjson::Value& json, const char* msg) {
-    const auto dump = json.toString();
-    LOG("!! %s: %s\n", msg, dump.c_str());
-    return false;
-}
-
 sk_sp<sksg::Matrix> AttachMatrix(const skjson::ObjectValue& t, AnimatorScope* ascope,
                                  sk_sp<sksg::Matrix> parentMatrix) {
     static const VectorValue g_default_vec_0   = {  0,   0},
@@ -144,107 +118,6 @@
         : nullptr;
 }
 
-sk_sp<sksg::GeometryNode> AttachPathGeometry(const skjson::ObjectValue& jpath,
-                                             AnimatorScope* ascope) {
-    return AttachPath(jpath["ks"], ascope);
-}
-
-sk_sp<sksg::GeometryNode> AttachRRectGeometry(const skjson::ObjectValue& jrect,
-                                              AnimatorScope* ascope) {
-    auto rect_node = sksg::RRect::Make();
-    auto adapter = sk_make_sp<RRectAdapter>(rect_node);
-
-    auto p_attached = BindProperty<VectorValue>(jrect["p"], ascope,
-        [adapter](const VectorValue& p) {
-            adapter->setPosition(ValueTraits<VectorValue>::As<SkPoint>(p));
-        });
-    auto s_attached = BindProperty<VectorValue>(jrect["s"], ascope,
-        [adapter](const VectorValue& s) {
-            adapter->setSize(ValueTraits<VectorValue>::As<SkSize>(s));
-        });
-    auto r_attached = BindProperty<ScalarValue>(jrect["r"], ascope,
-        [adapter](const ScalarValue& r) {
-            adapter->setRadius(SkSize::Make(r, r));
-        });
-
-    if (!p_attached && !s_attached && !r_attached) {
-        return nullptr;
-    }
-
-    return std::move(rect_node);
-}
-
-sk_sp<sksg::GeometryNode> AttachEllipseGeometry(const skjson::ObjectValue& jellipse,
-                                                AnimatorScope* ascope) {
-    auto rect_node = sksg::RRect::Make();
-    auto adapter = sk_make_sp<RRectAdapter>(rect_node);
-
-    auto p_attached = BindProperty<VectorValue>(jellipse["p"], ascope,
-        [adapter](const VectorValue& p) {
-            adapter->setPosition(ValueTraits<VectorValue>::As<SkPoint>(p));
-        });
-    auto s_attached = BindProperty<VectorValue>(jellipse["s"], ascope,
-        [adapter](const VectorValue& s) {
-            const auto sz = ValueTraits<VectorValue>::As<SkSize>(s);
-            adapter->setSize(sz);
-            adapter->setRadius(SkSize::Make(sz.width() / 2, sz.height() / 2));
-        });
-
-    if (!p_attached && !s_attached) {
-        return nullptr;
-    }
-
-    return std::move(rect_node);
-}
-
-sk_sp<sksg::GeometryNode> AttachPolystarGeometry(const skjson::ObjectValue& jstar,
-                                                 AnimatorScope* ascope) {
-    static constexpr PolyStarAdapter::Type gTypes[] = {
-        PolyStarAdapter::Type::kStar, // "sy": 1
-        PolyStarAdapter::Type::kPoly, // "sy": 2
-    };
-
-    const auto type = ParseDefault<size_t>(jstar["sy"], 0) - 1;
-    if (type >= SK_ARRAY_COUNT(gTypes)) {
-        LogFail(jstar, "Unknown polystar type");
-        return nullptr;
-    }
-
-    auto path_node = sksg::Path::Make();
-    auto adapter = sk_make_sp<PolyStarAdapter>(path_node, gTypes[type]);
-
-    BindProperty<VectorValue>(jstar["p"], ascope,
-        [adapter](const VectorValue& p) {
-            adapter->setPosition(ValueTraits<VectorValue>::As<SkPoint>(p));
-        });
-    BindProperty<ScalarValue>(jstar["pt"], ascope,
-        [adapter](const ScalarValue& pt) {
-            adapter->setPointCount(pt);
-        });
-    BindProperty<ScalarValue>(jstar["ir"], ascope,
-        [adapter](const ScalarValue& ir) {
-            adapter->setInnerRadius(ir);
-        });
-    BindProperty<ScalarValue>(jstar["or"], ascope,
-        [adapter](const ScalarValue& otr) {
-            adapter->setOuterRadius(otr);
-        });
-    BindProperty<ScalarValue>(jstar["is"], ascope,
-        [adapter](const ScalarValue& is) {
-            adapter->setInnerRoundness(is);
-        });
-    BindProperty<ScalarValue>(jstar["os"], ascope,
-        [adapter](const ScalarValue& os) {
-            adapter->setOuterRoundness(os);
-        });
-    BindProperty<ScalarValue>(jstar["r"], ascope,
-        [adapter](const ScalarValue& r) {
-            adapter->setRotation(r);
-        });
-
-    return std::move(path_node);
-}
-
 sk_sp<sksg::Color> AttachColor(const skjson::ObjectValue& jcolor, AnimatorScope* ascope,
                                const char prop_name[]) {
     auto color_node = sksg::Color::Make(SK_ColorBLACK);
@@ -256,1052 +129,6 @@
     return color_node;
 }
 
-sk_sp<sksg::Gradient> AttachGradient(const skjson::ObjectValue& jgrad, AnimatorScope* ascope) {
-    const skjson::ObjectValue* stops = jgrad["g"];
-    if (!stops)
-        return nullptr;
-
-    const auto stopCount = ParseDefault<int>((*stops)["p"], -1);
-    if (stopCount < 0)
-        return nullptr;
-
-    sk_sp<sksg::Gradient> gradient_node;
-    sk_sp<GradientAdapter> adapter;
-
-    if (ParseDefault<int>(jgrad["t"], 1) == 1) {
-        auto linear_node = sksg::LinearGradient::Make();
-        adapter = sk_make_sp<LinearGradientAdapter>(linear_node, stopCount);
-        gradient_node = std::move(linear_node);
-    } else {
-        auto radial_node = sksg::RadialGradient::Make();
-        adapter = sk_make_sp<RadialGradientAdapter>(radial_node, stopCount);
-
-        // TODO: highlight, angle
-        gradient_node = std::move(radial_node);
-    }
-
-    BindProperty<VectorValue>((*stops)["k"], ascope,
-        [adapter](const VectorValue& stops) {
-            adapter->setColorStops(stops);
-        });
-    BindProperty<VectorValue>(jgrad["s"], ascope,
-        [adapter](const VectorValue& s) {
-            adapter->setStartPoint(ValueTraits<VectorValue>::As<SkPoint>(s));
-        });
-    BindProperty<VectorValue>(jgrad["e"], ascope,
-        [adapter](const VectorValue& e) {
-            adapter->setEndPoint(ValueTraits<VectorValue>::As<SkPoint>(e));
-        });
-
-    return gradient_node;
-}
-
-sk_sp<sksg::PaintNode> AttachPaint(const skjson::ObjectValue& jpaint, AnimatorScope* ascope,
-                                   sk_sp<sksg::PaintNode> paint_node) {
-    if (paint_node) {
-        paint_node->setAntiAlias(true);
-
-        BindProperty<ScalarValue>(jpaint["o"], ascope,
-            [paint_node](const ScalarValue& o) {
-                // BM opacity is [0..100]
-                paint_node->setOpacity(o * 0.01f);
-        });
-    }
-
-    return paint_node;
-}
-
-sk_sp<sksg::PaintNode> AttachStroke(const skjson::ObjectValue& jstroke, AnimatorScope* ascope,
-                                    sk_sp<sksg::PaintNode> stroke_node) {
-    if (!stroke_node)
-        return nullptr;
-
-    stroke_node->setStyle(SkPaint::kStroke_Style);
-
-    BindProperty<ScalarValue>(jstroke["w"], ascope,
-        [stroke_node](const ScalarValue& w) {
-            stroke_node->setStrokeWidth(w);
-        });
-
-    stroke_node->setStrokeMiter(ParseDefault<SkScalar>(jstroke["ml"], 4.0f));
-
-    static constexpr SkPaint::Join gJoins[] = {
-        SkPaint::kMiter_Join,
-        SkPaint::kRound_Join,
-        SkPaint::kBevel_Join,
-    };
-    stroke_node->setStrokeJoin(gJoins[SkTMin<size_t>(ParseDefault<size_t>(jstroke["lj"], 1) - 1,
-                                                     SK_ARRAY_COUNT(gJoins) - 1)]);
-
-    static constexpr SkPaint::Cap gCaps[] = {
-        SkPaint::kButt_Cap,
-        SkPaint::kRound_Cap,
-        SkPaint::kSquare_Cap,
-    };
-    stroke_node->setStrokeCap(gCaps[SkTMin<size_t>(ParseDefault<size_t>(jstroke["lc"], 1) - 1,
-                                                   SK_ARRAY_COUNT(gCaps) - 1)]);
-
-    return stroke_node;
-}
-
-sk_sp<sksg::PaintNode> AttachColorFill(const skjson::ObjectValue& jfill, AnimatorScope* ascope) {
-    return AttachPaint(jfill, ascope, AttachColor(jfill, ascope, "c"));
-}
-
-sk_sp<sksg::PaintNode> AttachGradientFill(const skjson::ObjectValue& jfill, AnimatorScope* ascope) {
-    return AttachPaint(jfill, ascope, AttachGradient(jfill, ascope));
-}
-
-sk_sp<sksg::PaintNode> AttachColorStroke(const skjson::ObjectValue& jstroke,
-                                         AnimatorScope* ascope) {
-    return AttachStroke(jstroke, ascope, AttachPaint(jstroke, ascope,
-                                                     AttachColor(jstroke, ascope, "c")));
-}
-
-sk_sp<sksg::PaintNode> AttachGradientStroke(const skjson::ObjectValue& jstroke,
-                                            AnimatorScope* ascope) {
-    return AttachStroke(jstroke, ascope, AttachPaint(jstroke, ascope,
-                                                     AttachGradient(jstroke, ascope)));
-}
-
-sk_sp<sksg::Merge> Merge(std::vector<sk_sp<sksg::GeometryNode>>&& geos, sksg::Merge::Mode mode) {
-    std::vector<sksg::Merge::Rec> merge_recs;
-    merge_recs.reserve(geos.size());
-
-    for (const auto& geo : geos) {
-        merge_recs.push_back(
-            {std::move(geo), merge_recs.empty() ? sksg::Merge::Mode::kMerge : mode});
-    }
-
-    return sksg::Merge::Make(std::move(merge_recs));
-}
-
-std::vector<sk_sp<sksg::GeometryNode>> AttachMergeGeometryEffect(
-        const skjson::ObjectValue& jmerge, AnimatorScope*,
-        std::vector<sk_sp<sksg::GeometryNode>>&& geos) {
-    static constexpr sksg::Merge::Mode gModes[] = {
-        sksg::Merge::Mode::kMerge,      // "mm": 1
-        sksg::Merge::Mode::kUnion,      // "mm": 2
-        sksg::Merge::Mode::kDifference, // "mm": 3
-        sksg::Merge::Mode::kIntersect,  // "mm": 4
-        sksg::Merge::Mode::kXOR      ,  // "mm": 5
-    };
-
-    const auto mode = gModes[SkTMin<size_t>(ParseDefault<size_t>(jmerge["mm"], 1) - 1,
-                                            SK_ARRAY_COUNT(gModes) - 1)];
-
-    std::vector<sk_sp<sksg::GeometryNode>> merged;
-    merged.push_back(Merge(std::move(geos), mode));
-
-    return merged;
-}
-
-std::vector<sk_sp<sksg::GeometryNode>> AttachTrimGeometryEffect(
-        const skjson::ObjectValue& jtrim, AnimatorScope* ascope,
-        std::vector<sk_sp<sksg::GeometryNode>>&& geos) {
-
-    enum class Mode {
-        kMerged,   // "m": 1
-        kSeparate, // "m": 2
-    } gModes[] = { Mode::kMerged, Mode::kSeparate };
-
-    const auto mode = gModes[SkTMin<size_t>(ParseDefault<size_t>(jtrim["m"], 1) - 1,
-                                            SK_ARRAY_COUNT(gModes) - 1)];
-
-    std::vector<sk_sp<sksg::GeometryNode>> inputs;
-    if (mode == Mode::kMerged) {
-        inputs.push_back(Merge(std::move(geos), sksg::Merge::Mode::kMerge));
-    } else {
-        inputs = std::move(geos);
-    }
-
-    std::vector<sk_sp<sksg::GeometryNode>> trimmed;
-    trimmed.reserve(inputs.size());
-    for (const auto& i : inputs) {
-        const auto trimEffect = sksg::TrimEffect::Make(i);
-        trimmed.push_back(trimEffect);
-
-        const auto adapter = sk_make_sp<TrimEffectAdapter>(std::move(trimEffect));
-        BindProperty<ScalarValue>(jtrim["s"], ascope,
-            [adapter](const ScalarValue& s) {
-                adapter->setStart(s);
-            });
-        BindProperty<ScalarValue>(jtrim["e"], ascope,
-            [adapter](const ScalarValue& e) {
-                adapter->setEnd(e);
-            });
-        BindProperty<ScalarValue>(jtrim["o"], ascope,
-            [adapter](const ScalarValue& o) {
-                adapter->setOffset(o);
-            });
-    }
-
-    return trimmed;
-}
-
-std::vector<sk_sp<sksg::GeometryNode>> AttachRoundGeometryEffect(
-        const skjson::ObjectValue& jtrim, AnimatorScope* ascope,
-        std::vector<sk_sp<sksg::GeometryNode>>&& geos) {
-
-    std::vector<sk_sp<sksg::GeometryNode>> rounded;
-    rounded.reserve(geos.size());
-
-    for (const auto& g : geos) {
-        const auto roundEffect = sksg::RoundEffect::Make(std::move(g));
-        rounded.push_back(roundEffect);
-
-        BindProperty<ScalarValue>(jtrim["r"], ascope,
-            [roundEffect](const ScalarValue& r) {
-                roundEffect->setRadius(r);
-            });
-    }
-
-    return rounded;
-}
-
-using GeometryAttacherT = sk_sp<sksg::GeometryNode> (*)(const skjson::ObjectValue&, AnimatorScope*);
-static constexpr GeometryAttacherT gGeometryAttachers[] = {
-    AttachPathGeometry,
-    AttachRRectGeometry,
-    AttachEllipseGeometry,
-    AttachPolystarGeometry,
-};
-
-using PaintAttacherT = sk_sp<sksg::PaintNode> (*)(const skjson::ObjectValue&, AnimatorScope*);
-static constexpr PaintAttacherT gPaintAttachers[] = {
-    AttachColorFill,
-    AttachColorStroke,
-    AttachGradientFill,
-    AttachGradientStroke,
-};
-
-using GeometryEffectAttacherT =
-    std::vector<sk_sp<sksg::GeometryNode>> (*)(const skjson::ObjectValue&,
-                                               AnimatorScope*,
-                                               std::vector<sk_sp<sksg::GeometryNode>>&&);
-static constexpr GeometryEffectAttacherT gGeometryEffectAttachers[] = {
-    AttachMergeGeometryEffect,
-    AttachTrimGeometryEffect,
-    AttachRoundGeometryEffect,
-};
-
-enum class ShapeType {
-    kGeometry,
-    kGeometryEffect,
-    kPaint,
-    kGroup,
-    kTransform,
-};
-
-struct ShapeInfo {
-    const char* fTypeString;
-    ShapeType   fShapeType;
-    uint32_t    fAttacherIndex; // index into respective attacher tables
-};
-
-const ShapeInfo* FindShapeInfo(const skjson::ObjectValue& jshape) {
-    static constexpr ShapeInfo gShapeInfo[] = {
-        { "el", ShapeType::kGeometry      , 2 }, // ellipse   -> AttachEllipseGeometry
-        { "fl", ShapeType::kPaint         , 0 }, // fill      -> AttachColorFill
-        { "gf", ShapeType::kPaint         , 2 }, // gfill     -> AttachGradientFill
-        { "gr", ShapeType::kGroup         , 0 }, // group     -> Inline handler
-        { "gs", ShapeType::kPaint         , 3 }, // gstroke   -> AttachGradientStroke
-        { "mm", ShapeType::kGeometryEffect, 0 }, // merge     -> AttachMergeGeometryEffect
-        { "rc", ShapeType::kGeometry      , 1 }, // rrect     -> AttachRRectGeometry
-        { "rd", ShapeType::kGeometryEffect, 2 }, // round     -> AttachRoundGeometryEffect
-        { "sh", ShapeType::kGeometry      , 0 }, // shape     -> AttachPathGeometry
-        { "sr", ShapeType::kGeometry      , 3 }, // polystar  -> AttachPolyStarGeometry
-        { "st", ShapeType::kPaint         , 1 }, // stroke    -> AttachColorStroke
-        { "tm", ShapeType::kGeometryEffect, 1 }, // trim      -> AttachTrimGeometryEffect
-        { "tr", ShapeType::kTransform     , 0 }, // transform -> Inline handler
-    };
-
-    const skjson::StringValue* type = jshape["ty"];
-    if (!type) {
-        return nullptr;
-    }
-
-    const auto* info = bsearch(type->begin(),
-                               gShapeInfo,
-                               SK_ARRAY_COUNT(gShapeInfo),
-                               sizeof(ShapeInfo),
-                               [](const void* key, const void* info) {
-                                  return strcmp(static_cast<const char*>(key),
-                                                static_cast<const ShapeInfo*>(info)->fTypeString);
-                               });
-
-    return static_cast<const ShapeInfo*>(info);
-}
-
-struct GeometryEffectRec {
-    const skjson::ObjectValue& fJson;
-    GeometryEffectAttacherT    fAttach;
-};
-
-struct AttachShapeContext {
-    AttachShapeContext(AnimatorScope* ascope,
-                       std::vector<sk_sp<sksg::GeometryNode>>* geos,
-                       std::vector<GeometryEffectRec>* effects,
-                       size_t committedAnimators)
-        : fScope(ascope)
-        , fGeometryStack(geos)
-        , fGeometryEffectStack(effects)
-        , fCommittedAnimators(committedAnimators) {}
-
-    AnimatorScope*                          fScope;
-    std::vector<sk_sp<sksg::GeometryNode>>* fGeometryStack;
-    std::vector<GeometryEffectRec>*         fGeometryEffectStack;
-    size_t                                  fCommittedAnimators;
-};
-
-sk_sp<sksg::RenderNode> AttachShape(const skjson::ArrayValue* jshape,
-                                    AttachShapeContext* shapeCtx) {
-    if (!jshape)
-        return nullptr;
-
-    SkDEBUGCODE(const auto initialGeometryEffects = shapeCtx->fGeometryEffectStack->size();)
-
-    sk_sp<sksg::Group> shape_group = sksg::Group::Make();
-    sk_sp<sksg::RenderNode> shape_wrapper = shape_group;
-    sk_sp<sksg::Matrix> shape_matrix;
-
-    struct ShapeRec {
-        const skjson::ObjectValue& fJson;
-        const ShapeInfo&           fInfo;
-    };
-
-    // First pass (bottom->top):
-    //
-    //   * pick up the group transform and opacity
-    //   * push local geometry effects onto the stack
-    //   * store recs for next pass
-    //
-    std::vector<ShapeRec> recs;
-    for (size_t i = 0; i < jshape->size(); ++i) {
-        const skjson::ObjectValue* shape = (*jshape)[jshape->size() - 1 - i];
-        if (!shape) continue;
-
-        const auto* info = FindShapeInfo(*shape);
-        if (!info) {
-            LogFail((*shape)["ty"], "Unknown shape");
-            continue;
-        }
-
-        recs.push_back({ *shape, *info });
-
-        switch (info->fShapeType) {
-        case ShapeType::kTransform:
-            if ((shape_matrix = AttachMatrix(*shape, shapeCtx->fScope, nullptr))) {
-                shape_wrapper = sksg::Transform::Make(std::move(shape_wrapper), shape_matrix);
-            }
-            shape_wrapper = AttachOpacity(*shape, shapeCtx->fScope, std::move(shape_wrapper));
-            break;
-        case ShapeType::kGeometryEffect:
-            SkASSERT(info->fAttacherIndex < SK_ARRAY_COUNT(gGeometryEffectAttachers));
-            shapeCtx->fGeometryEffectStack->push_back(
-                { *shape, gGeometryEffectAttachers[info->fAttacherIndex] });
-            break;
-        default:
-            break;
-        }
-    }
-
-    // Second pass (top -> bottom, after 2x reverse):
-    //
-    //   * track local geometry
-    //   * emit local paints
-    //
-    std::vector<sk_sp<sksg::GeometryNode>> geos;
-    std::vector<sk_sp<sksg::RenderNode  >> draws;
-    for (auto rec = recs.rbegin(); rec != recs.rend(); ++rec) {
-        switch (rec->fInfo.fShapeType) {
-        case ShapeType::kGeometry: {
-            SkASSERT(rec->fInfo.fAttacherIndex < SK_ARRAY_COUNT(gGeometryAttachers));
-            if (auto geo = gGeometryAttachers[rec->fInfo.fAttacherIndex](rec->fJson,
-                                                                         shapeCtx->fScope)) {
-                geos.push_back(std::move(geo));
-            }
-        } break;
-        case ShapeType::kGeometryEffect: {
-            // Apply the current effect and pop from the stack.
-            SkASSERT(rec->fInfo.fAttacherIndex < SK_ARRAY_COUNT(gGeometryEffectAttachers));
-            if (!geos.empty()) {
-                geos = gGeometryEffectAttachers[rec->fInfo.fAttacherIndex](rec->fJson,
-                                                                           shapeCtx->fScope,
-                                                                           std::move(geos));
-            }
-
-            SkASSERT(&shapeCtx->fGeometryEffectStack->back().fJson == &rec->fJson);
-            SkASSERT(shapeCtx->fGeometryEffectStack->back().fAttach ==
-                     gGeometryEffectAttachers[rec->fInfo.fAttacherIndex]);
-            shapeCtx->fGeometryEffectStack->pop_back();
-        } break;
-        case ShapeType::kGroup: {
-            AttachShapeContext groupShapeCtx(shapeCtx->fScope,
-                                             &geos,
-                                             shapeCtx->fGeometryEffectStack,
-                                             shapeCtx->fCommittedAnimators);
-            if (auto subgroup = AttachShape(rec->fJson["it"], &groupShapeCtx)) {
-                draws.push_back(std::move(subgroup));
-                SkASSERT(groupShapeCtx.fCommittedAnimators >= shapeCtx->fCommittedAnimators);
-                shapeCtx->fCommittedAnimators = groupShapeCtx.fCommittedAnimators;
-            }
-        } break;
-        case ShapeType::kPaint: {
-            SkASSERT(rec->fInfo.fAttacherIndex < SK_ARRAY_COUNT(gPaintAttachers));
-            auto paint = gPaintAttachers[rec->fInfo.fAttacherIndex](rec->fJson, shapeCtx->fScope);
-            if (!paint || geos.empty())
-                break;
-
-            auto drawGeos = geos;
-
-            // Apply all pending effects from the stack.
-            for (auto it = shapeCtx->fGeometryEffectStack->rbegin();
-                 it != shapeCtx->fGeometryEffectStack->rend(); ++it) {
-                drawGeos = it->fAttach(it->fJson, shapeCtx->fScope, std::move(drawGeos));
-            }
-
-            // If we still have multiple geos, reduce using 'merge'.
-            auto geo = drawGeos.size() > 1
-                ? Merge(std::move(drawGeos), sksg::Merge::Mode::kMerge)
-                : drawGeos[0];
-
-            SkASSERT(geo);
-            draws.push_back(sksg::Draw::Make(std::move(geo), std::move(paint)));
-            shapeCtx->fCommittedAnimators = shapeCtx->fScope->size();
-        } break;
-        default:
-            break;
-        }
-    }
-
-    // By now we should have popped all local geometry effects.
-    SkASSERT(shapeCtx->fGeometryEffectStack->size() == initialGeometryEffects);
-
-    // Push transformed local geometries to parent list, for subsequent paints.
-    for (const auto& geo : geos) {
-        shapeCtx->fGeometryStack->push_back(shape_matrix
-            ? sksg::GeometryTransform::Make(std::move(geo), shape_matrix)
-            : std::move(geo));
-    }
-
-    // Emit local draws reversed (bottom->top, per spec).
-    for (auto it = draws.rbegin(); it != draws.rend(); ++it) {
-        shape_group->addChild(std::move(*it));
-    }
-
-    return draws.empty() ? nullptr : shape_wrapper;
-}
-
-} // namespace
-
-sk_sp<sksg::RenderNode> AnimationBuilder::attachNestedAnimation(const char* name,
-                                                                AnimatorScope* ascope) {
-    class SkottieSGAdapter final : public sksg::RenderNode {
-    public:
-        explicit SkottieSGAdapter(sk_sp<Animation> animation)
-            : fAnimation(std::move(animation)) {
-            SkASSERT(fAnimation);
-        }
-
-    protected:
-        SkRect onRevalidate(sksg::InvalidationController*, const SkMatrix&) override {
-            return SkRect::MakeSize(fAnimation->size());
-        }
-
-        void onRender(SkCanvas* canvas, const RenderContext* ctx) const override {
-            const auto local_scope =
-                ScopedRenderContext(canvas, ctx).setIsolation(this->bounds(), true);
-            fAnimation->render(canvas);
-        }
-
-    private:
-        const sk_sp<Animation> fAnimation;
-    };
-
-    class SkottieAnimatorAdapter final : public sksg::Animator {
-    public:
-        SkottieAnimatorAdapter(sk_sp<Animation> animation, float time_scale)
-            : fAnimation(std::move(animation))
-            , fTimeScale(time_scale) {
-            SkASSERT(fAnimation);
-        }
-
-    protected:
-        void onTick(float t) {
-            // TODO: we prolly need more sophisticated timeline mapping for nested animations.
-            fAnimation->seek(t * fTimeScale);
-        }
-
-    private:
-        const sk_sp<Animation> fAnimation;
-        const float            fTimeScale;
-    };
-
-    const auto data = fResourceProvider.load("", name);
-    if (!data) {
-        LOG("!! Could not load: %s\n", name);
-        return nullptr;
-    }
-
-    auto animation = Animation::Make(static_cast<const char*>(data->data()), data->size(),
-                                     &fResourceProvider);
-    if (!animation) {
-        LOG("!! Could not parse nested animation: %s\n", name);
-        return nullptr;
-    }
-
-
-    ascope->push_back(
-        skstd::make_unique<SkottieAnimatorAdapter>(animation, animation->duration() / fDuration));
-
-    return sk_make_sp<SkottieSGAdapter>(std::move(animation));
-}
-
-sk_sp<sksg::RenderNode> AnimationBuilder::attachAssetRef(
-    const skjson::ObjectValue& jlayer, AnimatorScope* ascope,
-    sk_sp<sksg::RenderNode>(AnimationBuilder::* attach_proc)(const skjson::ObjectValue& comp,
-                                                             AnimatorScope* ascope)) {
-
-    const auto refId = ParseDefault<SkString>(jlayer["refId"], SkString());
-    if (refId.isEmpty()) {
-        LOG("!! Layer missing refId\n");
-        return nullptr;
-    }
-
-    if (refId.startsWith("$")) {
-        return this->attachNestedAnimation(refId.c_str() + 1, ascope);
-    }
-
-    const auto* asset_info = fAssets.find(refId);
-    if (!asset_info) {
-        LOG("!! Asset not found: '%s'\n", refId.c_str());
-        return nullptr;
-    }
-
-    if (asset_info->fIsAttaching) {
-        LOG("!! Asset cycle detected for: '%s'\n", refId.c_str());
-        return nullptr;
-    }
-
-    asset_info->fIsAttaching = true;
-    auto asset = (this->*attach_proc)(*asset_info->fAsset, ascope);
-    asset_info->fIsAttaching = false;
-
-    return asset;
-}
-
-sk_sp<sksg::RenderNode> AnimationBuilder::attachPrecompLayer(const skjson::ObjectValue& jlayer,
-                                                             AnimatorScope* ascope) {
-    const skjson::ObjectValue* time_remap = jlayer["tm"];
-    const auto start_time = ParseDefault<float>(jlayer["st"], 0.0f),
-             stretch_time = ParseDefault<float>(jlayer["sr"], 1.0f);
-    const auto requires_time_mapping = !SkScalarNearlyEqual(start_time  , 0) ||
-                                       !SkScalarNearlyEqual(stretch_time, 1) ||
-                                       time_remap;
-
-    AnimatorScope local_animators;
-    auto comp_layer = this->attachAssetRef(jlayer,
-                                           requires_time_mapping ? &local_animators : ascope,
-                                           &AnimationBuilder::attachComposition);
-
-    // Applies a bias/scale/remap t-adjustment to child animators.
-    class CompTimeMapper final : public sksg::GroupAnimator {
-    public:
-        CompTimeMapper(sksg::AnimatorList&& layer_animators, float time_bias, float time_scale)
-            : INHERITED(std::move(layer_animators))
-            , fTimeBias(time_bias)
-            , fTimeScale(time_scale) {}
-
-        void onTick(float t) override {
-            // When time remapping is active, |t| is driven externally.
-            if (fRemappedTime.isValid()) {
-                t = *fRemappedTime.get();
-            }
-
-            this->INHERITED::onTick((t + fTimeBias) * fTimeScale);
-        }
-
-        void remapTime(float t) { fRemappedTime.set(t); }
-
-    private:
-        const float    fTimeBias,
-                       fTimeScale;
-        SkTLazy<float> fRemappedTime;
-
-        using INHERITED = sksg::GroupAnimator;
-    };
-
-    if (requires_time_mapping) {
-        const auto t_bias  = -start_time,
-                   t_scale = sk_ieee_float_divide(1, stretch_time);
-        auto time_mapper = skstd::make_unique<CompTimeMapper>(std::move(local_animators),
-                                                              t_bias, t_scale);
-        if (time_remap) {
-            // The lambda below captures a raw pointer to the mapper object.  That should be safe,
-            // because both the lambda and the mapper are scoped/owned by ctx->fAnimators.
-            auto* raw_mapper = time_mapper.get();
-            auto  frame_rate = fFrameRate;
-            BindProperty<ScalarValue>(*time_remap, ascope,
-                    [raw_mapper, frame_rate](const ScalarValue& t) {
-                        raw_mapper->remapTime(t * frame_rate);
-                    });
-        }
-        ascope->push_back(std::move(time_mapper));
-    }
-
-    return comp_layer;
-}
-
-sk_sp<sksg::RenderNode> AnimationBuilder::attachSolidLayer(const skjson::ObjectValue& jlayer,
-                                                           AnimatorScope*) {
-    const auto size = SkSize::Make(ParseDefault<float>(jlayer["sw"], 0.0f),
-                                   ParseDefault<float>(jlayer["sh"], 0.0f));
-    const skjson::StringValue* hex_str = jlayer["sc"];
-    uint32_t c;
-    if (size.isEmpty() ||
-        *hex_str->begin() != '#' ||
-        !SkParse::FindHex(hex_str->begin() + 1, &c)) {
-        LogFail(jlayer, "Could not parse solid layer");
-        return nullptr;
-    }
-
-    const SkColor color = 0xff000000 | c;
-
-    return sksg::Draw::Make(sksg::Rect::Make(SkRect::MakeSize(size)),
-                            sksg::Color::Make(color));
-}
-
-sk_sp<sksg::RenderNode> AnimationBuilder::attachImageAsset(const skjson::ObjectValue& jimage,
-                                                           AnimatorScope*) {
-    const skjson::StringValue* name = jimage["p"];
-    const skjson::StringValue* path = jimage["u"];
-    if (!name) {
-        return nullptr;
-    }
-
-    const auto name_cstr = name->begin(),
-               path_cstr = path ? path->begin() : "";
-    const auto res_id = SkStringPrintf("%s|%s", path_cstr, name_cstr);
-    if (auto* attached_image = fAssetCache.find(res_id)) {
-        return *attached_image;
-    }
-
-    const auto data = fResourceProvider.load(path_cstr, name_cstr);
-    if (!data) {
-        LOG("!! Could not load image resource: %s/%s\n", path_cstr, name_cstr);
-        return nullptr;
-    }
-
-    // TODO: non-intrisic image sizing
-    return *fAssetCache.set(res_id, sksg::Image::Make(SkImage::MakeFromEncoded(data)));
-}
-
-sk_sp<sksg::RenderNode> AnimationBuilder::attachImageLayer(const skjson::ObjectValue& jlayer,
-                                                           AnimatorScope* ascope) {
-    return this->attachAssetRef(jlayer, ascope, &AnimationBuilder::attachImageAsset);
-}
-
-sk_sp<sksg::RenderNode> AnimationBuilder::attachNullLayer(const skjson::ObjectValue& layer,
-                                                          AnimatorScope*) {
-    // Null layers are used solely to drive dependent transforms,
-    // but we use free-floating sksg::Matrices for that purpose.
-    return nullptr;
-}
-
-sk_sp<sksg::RenderNode> AnimationBuilder::attachShapeLayer(const skjson::ObjectValue& layer,
-                                                           AnimatorScope* ascope) {
-    std::vector<sk_sp<sksg::GeometryNode>> geometryStack;
-    std::vector<GeometryEffectRec> geometryEffectStack;
-    AttachShapeContext shapeCtx(ascope, &geometryStack, &geometryEffectStack, ascope->size());
-    auto shapeNode = AttachShape(layer["shapes"], &shapeCtx);
-
-    // Trim uncommitted animators: AttachShape consumes effects on the fly, and greedily attaches
-    // geometries => at the end, we can end up with unused geometries, which are nevertheless alive
-    // due to attached animators.  To avoid this, we track committed animators and discard the
-    // orphans here.
-    SkASSERT(shapeCtx.fCommittedAnimators <= ascope->size());
-    ascope->resize(shapeCtx.fCommittedAnimators);
-
-    return shapeNode;
-}
-
-struct AnimationBuilder::AttachLayerContext {
-    AttachLayerContext(const skjson::ArrayValue& jlayers, AnimatorScope* scope)
-        : fLayerList(jlayers), fScope(scope) {}
-
-    const skjson::ArrayValue&            fLayerList;
-    AnimatorScope*                       fScope;
-    SkTHashMap<int, sk_sp<sksg::Matrix>> fLayerMatrixMap;
-    sk_sp<sksg::RenderNode>              fCurrentMatte;
-
-    sk_sp<sksg::Matrix> AttachLayerMatrix(const skjson::ObjectValue& jlayer) {
-        const auto layer_index = ParseDefault<int>(jlayer["ind"], -1);
-        if (layer_index < 0)
-            return nullptr;
-
-        if (auto* m = fLayerMatrixMap.find(layer_index))
-            return *m;
-
-        return this->AttachLayerMatrixImpl(jlayer, layer_index);
-    }
-
-private:
-    sk_sp<sksg::Matrix> AttachParentLayerMatrix(const skjson::ObjectValue& jlayer,
-                                                int layer_index) {
-        const auto parent_index = ParseDefault<int>(jlayer["parent"], -1);
-        if (parent_index < 0 || parent_index == layer_index)
-            return nullptr;
-
-        if (auto* m = fLayerMatrixMap.find(parent_index))
-            return *m;
-
-        for (const skjson::ObjectValue* l : fLayerList) {
-            if (!l) continue;
-
-            if (ParseDefault<int>((*l)["ind"], -1) == parent_index) {
-                return this->AttachLayerMatrixImpl(*l, parent_index);
-            }
-        }
-
-        return nullptr;
-    }
-
-    sk_sp<sksg::Matrix> AttachLayerMatrixImpl(const skjson::ObjectValue& jlayer, int layer_index) {
-        SkASSERT(!fLayerMatrixMap.find(layer_index));
-
-        // Add a stub entry to break recursion cycles.
-        fLayerMatrixMap.set(layer_index, nullptr);
-
-        auto parent_matrix = this->AttachParentLayerMatrix(jlayer, layer_index);
-
-        if (const skjson::ObjectValue* jtransform = jlayer["ks"]) {
-            return *fLayerMatrixMap.set(layer_index, AttachMatrix(*jtransform, fScope,
-                                                                  std::move(parent_matrix)));
-
-        }
-        return nullptr;
-    }
-};
-
-namespace {
-
-struct MaskInfo {
-    SkBlendMode       fBlendMode;      // used when masking with layers/blending
-    sksg::Merge::Mode fMergeMode;      // used when clipping
-    bool              fInvertGeometry;
-};
-
-const MaskInfo* GetMaskInfo(char mode) {
-    static constexpr MaskInfo k_add_info =
-        { SkBlendMode::kSrcOver   , sksg::Merge::Mode::kUnion     , false };
-    static constexpr MaskInfo k_int_info =
-        { SkBlendMode::kSrcIn     , sksg::Merge::Mode::kIntersect , false };
-    // AE 'subtract' is the same as 'intersect' + inverted geometry
-    // (draws the opacity-adjusted paint *outside* the shape).
-    static constexpr MaskInfo k_sub_info =
-        { SkBlendMode::kSrcIn     , sksg::Merge::Mode::kIntersect , true  };
-    static constexpr MaskInfo k_dif_info =
-        { SkBlendMode::kDifference, sksg::Merge::Mode::kDifference, false };
-
-    switch (mode) {
-    case 'a': return &k_add_info;
-    case 'f': return &k_dif_info;
-    case 'i': return &k_int_info;
-    case 's': return &k_sub_info;
-    default: break;
-    }
-
-    return nullptr;
-}
-
-sk_sp<sksg::RenderNode> AttachMask(const skjson::ArrayValue* jmask,
-                                   AnimatorScope* ascope,
-                                   sk_sp<sksg::RenderNode> childNode) {
-    if (!jmask) return childNode;
-
-    struct MaskRecord {
-        sk_sp<sksg::Path>  mask_path;  // for clipping and masking
-        sk_sp<sksg::Color> mask_paint; // for masking
-        sksg::Merge::Mode  merge_mode; // for clipping
-    };
-
-    SkSTArray<4, MaskRecord, true> mask_stack;
-
-    bool has_opacity = false;
-
-    for (const skjson::ObjectValue* m : *jmask) {
-        if (!m) continue;
-
-        const skjson::StringValue* jmode = (*m)["mode"];
-        if (!jmode || jmode->size() != 1) {
-            LogFail((*m)["mode"], "Invalid mask mode");
-            continue;
-        }
-
-        const auto mode = *jmode->begin();
-        if (mode == 'n') {
-            // "None" masks have no effect.
-            continue;
-        }
-
-        const auto* mask_info = GetMaskInfo(mode);
-        if (!mask_info) {
-            LOG("?? Unsupported mask mode: '%c'\n", mode);
-            continue;
-        }
-
-        auto mask_path = AttachPath((*m)["pt"], ascope);
-        if (!mask_path) {
-            LogFail(*m, "Could not parse mask path");
-            continue;
-        }
-
-        // "inv" is cumulative with mask info fInvertGeometry
-        const auto inverted =
-            (mask_info->fInvertGeometry != ParseDefault<bool>((*m)["inv"], false));
-        mask_path->setFillType(inverted ? SkPath::kInverseWinding_FillType
-                                        : SkPath::kWinding_FillType);
-
-        auto mask_paint = sksg::Color::Make(SK_ColorBLACK);
-        mask_paint->setAntiAlias(true);
-        // First mask in the stack initializes the mask buffer.
-        mask_paint->setBlendMode(mask_stack.empty() ? SkBlendMode::kSrc
-                                                    : mask_info->fBlendMode);
-
-        has_opacity |= BindProperty<ScalarValue>((*m)["o"], ascope,
-            [mask_paint](const ScalarValue& o) {
-                mask_paint->setOpacity(o * 0.01f);
-        }, 100.0f);
-
-        mask_stack.push_back({mask_path, mask_paint, mask_info->fMergeMode});
-    }
-
-    if (mask_stack.empty())
-        return childNode;
-
-    // If the masks are fully opaque, we can clip.
-    if (!has_opacity) {
-        sk_sp<sksg::GeometryNode> clip_node;
-
-        if (mask_stack.count() == 1) {
-            // Single path -> just clip.
-            clip_node = std::move(mask_stack.front().mask_path);
-        } else {
-            // Multiple clip paths -> merge.
-            std::vector<sksg::Merge::Rec> merge_recs;
-            merge_recs.reserve(SkToSizeT(mask_stack.count()));
-
-            for (const auto& mask : mask_stack) {
-                const auto mode = merge_recs.empty() ? sksg::Merge::Mode::kMerge : mask.merge_mode;
-                merge_recs.push_back({std::move(mask.mask_path), mode});
-            }
-            clip_node = sksg::Merge::Make(std::move(merge_recs));
-        }
-
-        return sksg::ClipEffect::Make(std::move(childNode), std::move(clip_node), true);
-    }
-
-    auto mask_group = sksg::Group::Make();
-    for (const auto& rec : mask_stack) {
-        mask_group->addChild(sksg::Draw::Make(std::move(rec.mask_path),
-                                              std::move(rec.mask_paint)));
-
-    }
-
-    return sksg::MaskEffect::Make(std::move(childNode), std::move(mask_group));
-}
-
-
-sk_sp<sksg::RenderNode> AttachFillLayerEffect(const skjson::ArrayValue* jeffect_props,
-                                              AnimatorScope* ascope,
-                                              sk_sp<sksg::RenderNode> layer) {
-    if (!jeffect_props) return layer;
-
-    sk_sp<sksg::Color> color_node;
-
-    for (const skjson::ObjectValue* jprop : *jeffect_props) {
-        if (!jprop) continue;
-
-        switch (const auto ty = ParseDefault<int>((*jprop)["ty"], -1)) {
-        case 2: // color
-            color_node = AttachColor(*jprop, ascope, "v");
-            break;
-        default:
-            LOG("?? Ignoring unsupported fill effect poperty type: %d\n", ty);
-            break;
-        }
-    }
-
-    return color_node
-        ? sksg::ColorModeFilter::Make(std::move(layer), std::move(color_node), SkBlendMode::kSrcIn)
-        : nullptr;
-}
-
-} // namespace
-
-sk_sp<sksg::RenderNode> AnimationBuilder::attachLayerEffects(const skjson::ArrayValue& jeffects,
-                                                             AnimatorScope* ascope,
-                                                             sk_sp<sksg::RenderNode> layer) {
-    for (const skjson::ObjectValue* jeffect : jeffects) {
-        if (!jeffect) continue;
-
-        switch (const auto ty = ParseDefault<int>((*jeffect)["ty"], -1)) {
-        case 21: // Fill
-            layer = AttachFillLayerEffect((*jeffect)["ef"], ascope, std::move(layer));
-            break;
-        default:
-            LOG("?? Unsupported layer effect type: %d\n", ty);
-            break;
-        }
-    }
-
-    return layer;
-}
-
-sk_sp<sksg::RenderNode> AnimationBuilder::attachLayer(const skjson::ObjectValue* jlayer,
-                                                     AttachLayerContext* layerCtx) {
-    if (!jlayer) return nullptr;
-
-    using LayerAttacher = sk_sp<sksg::RenderNode> (AnimationBuilder::*)(const skjson::ObjectValue&,
-                                                                        AnimatorScope*);
-    static constexpr LayerAttacher gLayerAttachers[] = {
-        &AnimationBuilder::attachPrecompLayer,  // 'ty': 0
-        &AnimationBuilder::attachSolidLayer,    // 'ty': 1
-        &AnimationBuilder::attachImageLayer,    // 'ty': 2
-        &AnimationBuilder::attachNullLayer,     // 'ty': 3
-        &AnimationBuilder::attachShapeLayer,    // 'ty': 4
-        &AnimationBuilder::attachTextLayer,     // 'ty': 5
-    };
-
-    int type = ParseDefault<int>((*jlayer)["ty"], -1);
-    if (type < 0 || type >= SkTo<int>(SK_ARRAY_COUNT(gLayerAttachers))) {
-        return nullptr;
-    }
-
-    AnimatorScope layer_animators;
-
-    // Layer content.
-    auto layer = (this->*(gLayerAttachers[type]))(*jlayer, &layer_animators);
-
-    // Clip layers with explicit dimensions.
-    float w = 0, h = 0;
-    if (Parse<float>((*jlayer)["w"], &w) && Parse<float>((*jlayer)["h"], &h)) {
-        layer = sksg::ClipEffect::Make(std::move(layer),
-                                       sksg::Rect::Make(SkRect::MakeWH(w, h)),
-                                       true);
-    }
-
-    // Optional layer mask.
-    layer = AttachMask((*jlayer)["masksProperties"], &layer_animators, std::move(layer));
-
-    // Optional layer transform.
-    if (auto layerMatrix = layerCtx->AttachLayerMatrix(*jlayer)) {
-        layer = sksg::Transform::Make(std::move(layer), std::move(layerMatrix));
-    }
-
-    // Optional layer opacity.
-    // TODO: de-dupe this "ks" lookup with matrix above.
-    if (const skjson::ObjectValue* jtransform = (*jlayer)["ks"]) {
-        layer = AttachOpacity(*jtransform, &layer_animators, std::move(layer));
-    }
-
-    // Optional layer effects.
-    if (const skjson::ArrayValue* jeffects = (*jlayer)["ef"]) {
-        layer = this->attachLayerEffects(*jeffects, &layer_animators, std::move(layer));
-    }
-
-    class LayerController final : public sksg::GroupAnimator {
-    public:
-        LayerController(sksg::AnimatorList&& layer_animators,
-                        sk_sp<sksg::OpacityEffect> controlNode,
-                        float in, float out)
-            : INHERITED(std::move(layer_animators))
-            , fControlNode(std::move(controlNode))
-            , fIn(in)
-            , fOut(out) {}
-
-        void onTick(float t) override {
-            const auto active = (t >= fIn && t <= fOut);
-
-            // Keep the layer fully transparent except for its [in..out] lifespan.
-            // (note: opacity == 0 disables rendering, while opacity == 1 is a noop)
-            fControlNode->setOpacity(active ? 1 : 0);
-
-            // Dispatch ticks only while active.
-            if (active) this->INHERITED::onTick(t);
-        }
-
-    private:
-        const sk_sp<sksg::OpacityEffect> fControlNode;
-        const float                      fIn,
-                                         fOut;
-
-        using INHERITED = sksg::GroupAnimator;
-    };
-
-    auto controller_node = sksg::OpacityEffect::Make(std::move(layer));
-    const auto        in = ParseDefault<float>((*jlayer)["ip"], 0.0f),
-                     out = ParseDefault<float>((*jlayer)["op"], in);
-
-    if (in >= out || !controller_node)
-        return nullptr;
-
-    layerCtx->fScope->push_back(
-        skstd::make_unique<LayerController>(std::move(layer_animators), controller_node, in, out));
-
-    if (ParseDefault<bool>((*jlayer)["td"], false)) {
-        // This layer is a matte.  We apply it as a mask to the next layer.
-        layerCtx->fCurrentMatte = std::move(controller_node);
-        return nullptr;
-    }
-
-    if (layerCtx->fCurrentMatte) {
-        // There is a pending matte. Apply and reset.
-        static constexpr sksg::MaskEffect::Mode gMaskModes[] = {
-            sksg::MaskEffect::Mode::kNormal, // tt: 1
-            sksg::MaskEffect::Mode::kInvert, // tt: 2
-        };
-        const auto matteType = ParseDefault<size_t>((*jlayer)["tt"], 1) - 1;
-
-        if (matteType < SK_ARRAY_COUNT(gMaskModes)) {
-            return sksg::MaskEffect::Make(std::move(controller_node),
-                                          std::move(layerCtx->fCurrentMatte),
-                                          gMaskModes[matteType]);
-        }
-        layerCtx->fCurrentMatte.reset();
-    }
-
-    return std::move(controller_node);
-}
-
-sk_sp<sksg::RenderNode> AnimationBuilder::attachComposition(const skjson::ObjectValue& comp,
-                                                            AnimatorScope* scope) {
-    const skjson::ArrayValue* jlayers = comp["layers"];
-    if (!jlayers) return nullptr;
-
-    SkSTArray<16, sk_sp<sksg::RenderNode>, true> layers;
-    AttachLayerContext                           layerCtx(*jlayers, scope);
-
-    for (const auto& l : *jlayers) {
-        if (auto layer_fragment = this->attachLayer(l, &layerCtx)) {
-            layers.push_back(std::move(layer_fragment));
-        }
-    }
-
-    if (layers.empty()) {
-        return nullptr;
-    }
-
-    // Layers are painted in bottom->top order.
-    auto comp_group = sksg::Group::Make();
-    for (int i = layers.count() - 1; i >= 0; --i) {
-        comp_group->addChild(std::move(layers[i]));
-    }
-
-    return std::move(comp_group);
-}
-
 AnimationBuilder::AnimationBuilder(const ResourceProvider& rp, sk_sp<SkFontMgr> fontmgr,
                                   Animation::Stats* stats, float duration, float framerate)
     : fResourceProvider(rp)
diff --git a/modules/skottie/src/SkottieLayer.cpp b/modules/skottie/src/SkottieLayer.cpp
new file mode 100644
index 0000000..457e535
--- /dev/null
+++ b/modules/skottie/src/SkottieLayer.cpp
@@ -0,0 +1,519 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+#include "SkottiePriv.h"
+
+#include "SkData.h"
+#include "SkImage.h"
+#include "SkJSON.h"
+#include "SkMakeUnique.h"
+#include "SkottieAnimator.h"
+#include "SkottieJson.h"
+#include "SkottieValue.h"
+#include "SkParse.h"
+#include "SkSGClipEffect.h"
+#include "SkSGColor.h"
+#include "SkSGDraw.h"
+#include "SkSGGroup.h"
+#include "SkSGImage.h"
+#include "SkSGMaskEffect.h"
+#include "SkSGMerge.h"
+#include "SkSGOpacityEffect.h"
+#include "SkSGPath.h"
+#include "SkSGRect.h"
+#include "SkSGTransform.h"
+
+#include <vector>
+
+namespace skottie {
+namespace internal {
+
+namespace {
+
+struct MaskInfo {
+    SkBlendMode       fBlendMode;      // used when masking with layers/blending
+    sksg::Merge::Mode fMergeMode;      // used when clipping
+    bool              fInvertGeometry;
+};
+
+const MaskInfo* GetMaskInfo(char mode) {
+    static constexpr MaskInfo k_add_info =
+        { SkBlendMode::kSrcOver   , sksg::Merge::Mode::kUnion     , false };
+    static constexpr MaskInfo k_int_info =
+        { SkBlendMode::kSrcIn     , sksg::Merge::Mode::kIntersect , false };
+    // AE 'subtract' is the same as 'intersect' + inverted geometry
+    // (draws the opacity-adjusted paint *outside* the shape).
+    static constexpr MaskInfo k_sub_info =
+        { SkBlendMode::kSrcIn     , sksg::Merge::Mode::kIntersect , true  };
+    static constexpr MaskInfo k_dif_info =
+        { SkBlendMode::kDifference, sksg::Merge::Mode::kDifference, false };
+
+    switch (mode) {
+    case 'a': return &k_add_info;
+    case 'f': return &k_dif_info;
+    case 'i': return &k_int_info;
+    case 's': return &k_sub_info;
+    default: break;
+    }
+
+    return nullptr;
+}
+
+sk_sp<sksg::RenderNode> AttachMask(const skjson::ArrayValue* jmask,
+                                   AnimatorScope* ascope,
+                                   sk_sp<sksg::RenderNode> childNode) {
+    if (!jmask) return childNode;
+
+    struct MaskRecord {
+        sk_sp<sksg::Path>  mask_path;  // for clipping and masking
+        sk_sp<sksg::Color> mask_paint; // for masking
+        sksg::Merge::Mode  merge_mode; // for clipping
+    };
+
+    SkSTArray<4, MaskRecord, true> mask_stack;
+
+    bool has_opacity = false;
+
+    for (const skjson::ObjectValue* m : *jmask) {
+        if (!m) continue;
+
+        const skjson::StringValue* jmode = (*m)["mode"];
+        if (!jmode || jmode->size() != 1) {
+            LogJSON((*m)["mode"], "!! Invalid mask mode");
+            continue;
+        }
+
+        const auto mode = *jmode->begin();
+        if (mode == 'n') {
+            // "None" masks have no effect.
+            continue;
+        }
+
+        const auto* mask_info = GetMaskInfo(mode);
+        if (!mask_info) {
+            LOG("?? Unsupported mask mode: '%c'\n", mode);
+            continue;
+        }
+
+        auto mask_path = AttachPath((*m)["pt"], ascope);
+        if (!mask_path) {
+            LogJSON(*m, "!! Could not parse mask path");
+            continue;
+        }
+
+        // "inv" is cumulative with mask info fInvertGeometry
+        const auto inverted =
+            (mask_info->fInvertGeometry != ParseDefault<bool>((*m)["inv"], false));
+        mask_path->setFillType(inverted ? SkPath::kInverseWinding_FillType
+                                        : SkPath::kWinding_FillType);
+
+        auto mask_paint = sksg::Color::Make(SK_ColorBLACK);
+        mask_paint->setAntiAlias(true);
+        // First mask in the stack initializes the mask buffer.
+        mask_paint->setBlendMode(mask_stack.empty() ? SkBlendMode::kSrc
+                                                    : mask_info->fBlendMode);
+
+        has_opacity |= BindProperty<ScalarValue>((*m)["o"], ascope,
+            [mask_paint](const ScalarValue& o) {
+                mask_paint->setOpacity(o * 0.01f);
+        }, 100.0f);
+
+        mask_stack.push_back({mask_path, mask_paint, mask_info->fMergeMode});
+    }
+
+    if (mask_stack.empty())
+        return childNode;
+
+    // If the masks are fully opaque, we can clip.
+    if (!has_opacity) {
+        sk_sp<sksg::GeometryNode> clip_node;
+
+        if (mask_stack.count() == 1) {
+            // Single path -> just clip.
+            clip_node = std::move(mask_stack.front().mask_path);
+        } else {
+            // Multiple clip paths -> merge.
+            std::vector<sksg::Merge::Rec> merge_recs;
+            merge_recs.reserve(SkToSizeT(mask_stack.count()));
+
+            for (const auto& mask : mask_stack) {
+                const auto mode = merge_recs.empty() ? sksg::Merge::Mode::kMerge : mask.merge_mode;
+                merge_recs.push_back({std::move(mask.mask_path), mode});
+            }
+            clip_node = sksg::Merge::Make(std::move(merge_recs));
+        }
+
+        return sksg::ClipEffect::Make(std::move(childNode), std::move(clip_node), true);
+    }
+
+    auto mask_group = sksg::Group::Make();
+    for (const auto& rec : mask_stack) {
+        mask_group->addChild(sksg::Draw::Make(std::move(rec.mask_path),
+                                              std::move(rec.mask_paint)));
+
+    }
+
+    return sksg::MaskEffect::Make(std::move(childNode), std::move(mask_group));
+}
+
+} // namespace
+
+sk_sp<sksg::RenderNode> AnimationBuilder::attachNestedAnimation(const char* name,
+                                                                AnimatorScope* ascope) {
+    class SkottieSGAdapter final : public sksg::RenderNode {
+    public:
+        explicit SkottieSGAdapter(sk_sp<Animation> animation)
+            : fAnimation(std::move(animation)) {
+            SkASSERT(fAnimation);
+        }
+
+    protected:
+        SkRect onRevalidate(sksg::InvalidationController*, const SkMatrix&) override {
+            return SkRect::MakeSize(fAnimation->size());
+        }
+
+        void onRender(SkCanvas* canvas, const RenderContext* ctx) const override {
+            const auto local_scope =
+                ScopedRenderContext(canvas, ctx).setIsolation(this->bounds(), true);
+            fAnimation->render(canvas);
+        }
+
+    private:
+        const sk_sp<Animation> fAnimation;
+    };
+
+    class SkottieAnimatorAdapter final : public sksg::Animator {
+    public:
+        SkottieAnimatorAdapter(sk_sp<Animation> animation, float time_scale)
+            : fAnimation(std::move(animation))
+            , fTimeScale(time_scale) {
+            SkASSERT(fAnimation);
+        }
+
+    protected:
+        void onTick(float t) {
+            // TODO: we prolly need more sophisticated timeline mapping for nested animations.
+            fAnimation->seek(t * fTimeScale);
+        }
+
+    private:
+        const sk_sp<Animation> fAnimation;
+        const float            fTimeScale;
+    };
+
+    const auto data = fResourceProvider.load("", name);
+    if (!data) {
+        LOG("!! Could not load: %s\n", name);
+        return nullptr;
+    }
+
+    auto animation = Animation::Make(static_cast<const char*>(data->data()), data->size(),
+                                     &fResourceProvider);
+    if (!animation) {
+        LOG("!! Could not parse nested animation: %s\n", name);
+        return nullptr;
+    }
+
+
+    ascope->push_back(
+        skstd::make_unique<SkottieAnimatorAdapter>(animation, animation->duration() / fDuration));
+
+    return sk_make_sp<SkottieSGAdapter>(std::move(animation));
+}
+
+sk_sp<sksg::RenderNode> AnimationBuilder::attachAssetRef(
+    const skjson::ObjectValue& jlayer, AnimatorScope* ascope,
+    sk_sp<sksg::RenderNode>(AnimationBuilder::* attach_proc)(const skjson::ObjectValue& comp,
+                                                             AnimatorScope* ascope)) {
+
+    const auto refId = ParseDefault<SkString>(jlayer["refId"], SkString());
+    if (refId.isEmpty()) {
+        LOG("!! Layer missing refId\n");
+        return nullptr;
+    }
+
+    if (refId.startsWith("$")) {
+        return this->attachNestedAnimation(refId.c_str() + 1, ascope);
+    }
+
+    const auto* asset_info = fAssets.find(refId);
+    if (!asset_info) {
+        LOG("!! Asset not found: '%s'\n", refId.c_str());
+        return nullptr;
+    }
+
+    if (asset_info->fIsAttaching) {
+        LOG("!! Asset cycle detected for: '%s'\n", refId.c_str());
+        return nullptr;
+    }
+
+    asset_info->fIsAttaching = true;
+    auto asset = (this->*attach_proc)(*asset_info->fAsset, ascope);
+    asset_info->fIsAttaching = false;
+
+    return asset;
+}
+
+sk_sp<sksg::RenderNode> AnimationBuilder::attachSolidLayer(const skjson::ObjectValue& jlayer,
+                                                           AnimatorScope*) {
+    const auto size = SkSize::Make(ParseDefault<float>(jlayer["sw"], 0.0f),
+                                   ParseDefault<float>(jlayer["sh"], 0.0f));
+    const skjson::StringValue* hex_str = jlayer["sc"];
+    uint32_t c;
+    if (size.isEmpty() ||
+        *hex_str->begin() != '#' ||
+        !SkParse::FindHex(hex_str->begin() + 1, &c)) {
+        LogJSON(jlayer, "!! Could not parse solid layer");
+        return nullptr;
+    }
+
+    const SkColor color = 0xff000000 | c;
+
+    return sksg::Draw::Make(sksg::Rect::Make(SkRect::MakeSize(size)),
+                            sksg::Color::Make(color));
+}
+
+sk_sp<sksg::RenderNode> AnimationBuilder::attachImageAsset(const skjson::ObjectValue& jimage,
+                                                           AnimatorScope*) {
+    const skjson::StringValue* name = jimage["p"];
+    const skjson::StringValue* path = jimage["u"];
+    if (!name) {
+        return nullptr;
+    }
+
+    const auto name_cstr = name->begin(),
+               path_cstr = path ? path->begin() : "";
+    const auto res_id = SkStringPrintf("%s|%s", path_cstr, name_cstr);
+    if (auto* attached_image = fAssetCache.find(res_id)) {
+        return *attached_image;
+    }
+
+    const auto data = fResourceProvider.load(path_cstr, name_cstr);
+    if (!data) {
+        LOG("!! Could not load image resource: %s/%s\n", path_cstr, name_cstr);
+        return nullptr;
+    }
+
+    // TODO: non-intrisic image sizing
+    return *fAssetCache.set(res_id, sksg::Image::Make(SkImage::MakeFromEncoded(data)));
+}
+
+sk_sp<sksg::RenderNode> AnimationBuilder::attachImageLayer(const skjson::ObjectValue& jlayer,
+                                                           AnimatorScope* ascope) {
+    return this->attachAssetRef(jlayer, ascope, &AnimationBuilder::attachImageAsset);
+}
+
+sk_sp<sksg::RenderNode> AnimationBuilder::attachNullLayer(const skjson::ObjectValue& layer,
+                                                          AnimatorScope*) {
+    // Null layers are used solely to drive dependent transforms,
+    // but we use free-floating sksg::Matrices for that purpose.
+    return nullptr;
+}
+
+struct AnimationBuilder::AttachLayerContext {
+    AttachLayerContext(const skjson::ArrayValue& jlayers, AnimatorScope* scope)
+        : fLayerList(jlayers), fScope(scope) {}
+
+    const skjson::ArrayValue&            fLayerList;
+    AnimatorScope*                       fScope;
+    SkTHashMap<int, sk_sp<sksg::Matrix>> fLayerMatrixMap;
+    sk_sp<sksg::RenderNode>              fCurrentMatte;
+
+    sk_sp<sksg::Matrix> AttachLayerMatrix(const skjson::ObjectValue& jlayer) {
+        const auto layer_index = ParseDefault<int>(jlayer["ind"], -1);
+        if (layer_index < 0)
+            return nullptr;
+
+        if (auto* m = fLayerMatrixMap.find(layer_index))
+            return *m;
+
+        return this->AttachLayerMatrixImpl(jlayer, layer_index);
+    }
+
+private:
+    sk_sp<sksg::Matrix> AttachParentLayerMatrix(const skjson::ObjectValue& jlayer,
+                                                int layer_index) {
+        const auto parent_index = ParseDefault<int>(jlayer["parent"], -1);
+        if (parent_index < 0 || parent_index == layer_index)
+            return nullptr;
+
+        if (auto* m = fLayerMatrixMap.find(parent_index))
+            return *m;
+
+        for (const skjson::ObjectValue* l : fLayerList) {
+            if (!l) continue;
+
+            if (ParseDefault<int>((*l)["ind"], -1) == parent_index) {
+                return this->AttachLayerMatrixImpl(*l, parent_index);
+            }
+        }
+
+        return nullptr;
+    }
+
+    sk_sp<sksg::Matrix> AttachLayerMatrixImpl(const skjson::ObjectValue& jlayer, int layer_index) {
+        SkASSERT(!fLayerMatrixMap.find(layer_index));
+
+        // Add a stub entry to break recursion cycles.
+        fLayerMatrixMap.set(layer_index, nullptr);
+
+        auto parent_matrix = this->AttachParentLayerMatrix(jlayer, layer_index);
+
+        if (const skjson::ObjectValue* jtransform = jlayer["ks"]) {
+            return *fLayerMatrixMap.set(layer_index, AttachMatrix(*jtransform, fScope,
+                                                                  std::move(parent_matrix)));
+
+        }
+        return nullptr;
+    }
+};
+
+sk_sp<sksg::RenderNode> AnimationBuilder::attachLayer(const skjson::ObjectValue* jlayer,
+                                                     AttachLayerContext* layerCtx) {
+    if (!jlayer) return nullptr;
+
+    using LayerAttacher = sk_sp<sksg::RenderNode> (AnimationBuilder::*)(const skjson::ObjectValue&,
+                                                                        AnimatorScope*);
+    static constexpr LayerAttacher gLayerAttachers[] = {
+        &AnimationBuilder::attachPrecompLayer,  // 'ty': 0
+        &AnimationBuilder::attachSolidLayer,    // 'ty': 1
+        &AnimationBuilder::attachImageLayer,    // 'ty': 2
+        &AnimationBuilder::attachNullLayer,     // 'ty': 3
+        &AnimationBuilder::attachShapeLayer,    // 'ty': 4
+        &AnimationBuilder::attachTextLayer,     // 'ty': 5
+    };
+
+    int type = ParseDefault<int>((*jlayer)["ty"], -1);
+    if (type < 0 || type >= SkTo<int>(SK_ARRAY_COUNT(gLayerAttachers))) {
+        return nullptr;
+    }
+
+    AnimatorScope layer_animators;
+
+    // Layer content.
+    auto layer = (this->*(gLayerAttachers[type]))(*jlayer, &layer_animators);
+
+    // Clip layers with explicit dimensions.
+    float w = 0, h = 0;
+    if (Parse<float>((*jlayer)["w"], &w) && Parse<float>((*jlayer)["h"], &h)) {
+        layer = sksg::ClipEffect::Make(std::move(layer),
+                                       sksg::Rect::Make(SkRect::MakeWH(w, h)),
+                                       true);
+    }
+
+    // Optional layer mask.
+    layer = AttachMask((*jlayer)["masksProperties"], &layer_animators, std::move(layer));
+
+    // Optional layer transform.
+    if (auto layerMatrix = layerCtx->AttachLayerMatrix(*jlayer)) {
+        layer = sksg::Transform::Make(std::move(layer), std::move(layerMatrix));
+    }
+
+    // Optional layer opacity.
+    // TODO: de-dupe this "ks" lookup with matrix above.
+    if (const skjson::ObjectValue* jtransform = (*jlayer)["ks"]) {
+        layer = AttachOpacity(*jtransform, &layer_animators, std::move(layer));
+    }
+
+    // Optional layer effects.
+    if (const skjson::ArrayValue* jeffects = (*jlayer)["ef"]) {
+        layer = this->attachLayerEffects(*jeffects, &layer_animators, std::move(layer));
+    }
+
+    class LayerController final : public sksg::GroupAnimator {
+    public:
+        LayerController(sksg::AnimatorList&& layer_animators,
+                        sk_sp<sksg::OpacityEffect> controlNode,
+                        float in, float out)
+            : INHERITED(std::move(layer_animators))
+            , fControlNode(std::move(controlNode))
+            , fIn(in)
+            , fOut(out) {}
+
+        void onTick(float t) override {
+            const auto active = (t >= fIn && t <= fOut);
+
+            // Keep the layer fully transparent except for its [in..out] lifespan.
+            // (note: opacity == 0 disables rendering, while opacity == 1 is a noop)
+            fControlNode->setOpacity(active ? 1 : 0);
+
+            // Dispatch ticks only while active.
+            if (active) this->INHERITED::onTick(t);
+        }
+
+    private:
+        const sk_sp<sksg::OpacityEffect> fControlNode;
+        const float                      fIn,
+                                         fOut;
+
+        using INHERITED = sksg::GroupAnimator;
+    };
+
+    auto controller_node = sksg::OpacityEffect::Make(std::move(layer));
+    const auto        in = ParseDefault<float>((*jlayer)["ip"], 0.0f),
+                     out = ParseDefault<float>((*jlayer)["op"], in);
+
+    if (in >= out || !controller_node)
+        return nullptr;
+
+    layerCtx->fScope->push_back(
+        skstd::make_unique<LayerController>(std::move(layer_animators), controller_node, in, out));
+
+    if (ParseDefault<bool>((*jlayer)["td"], false)) {
+        // This layer is a matte.  We apply it as a mask to the next layer.
+        layerCtx->fCurrentMatte = std::move(controller_node);
+        return nullptr;
+    }
+
+    if (layerCtx->fCurrentMatte) {
+        // There is a pending matte. Apply and reset.
+        static constexpr sksg::MaskEffect::Mode gMaskModes[] = {
+            sksg::MaskEffect::Mode::kNormal, // tt: 1
+            sksg::MaskEffect::Mode::kInvert, // tt: 2
+        };
+        const auto matteType = ParseDefault<size_t>((*jlayer)["tt"], 1) - 1;
+
+        if (matteType < SK_ARRAY_COUNT(gMaskModes)) {
+            return sksg::MaskEffect::Make(std::move(controller_node),
+                                          std::move(layerCtx->fCurrentMatte),
+                                          gMaskModes[matteType]);
+        }
+        layerCtx->fCurrentMatte.reset();
+    }
+
+    return std::move(controller_node);
+}
+
+sk_sp<sksg::RenderNode> AnimationBuilder::attachComposition(const skjson::ObjectValue& comp,
+                                                            AnimatorScope* scope) {
+    const skjson::ArrayValue* jlayers = comp["layers"];
+    if (!jlayers) return nullptr;
+
+    SkSTArray<16, sk_sp<sksg::RenderNode>, true> layers;
+    AttachLayerContext                           layerCtx(*jlayers, scope);
+
+    for (const auto& l : *jlayers) {
+        if (auto layer_fragment = this->attachLayer(l, &layerCtx)) {
+            layers.push_back(std::move(layer_fragment));
+        }
+    }
+
+    if (layers.empty()) {
+        return nullptr;
+    }
+
+    // Layers are painted in bottom->top order.
+    auto comp_group = sksg::Group::Make();
+    for (int i = layers.count() - 1; i >= 0; --i) {
+        comp_group->addChild(std::move(layers[i]));
+    }
+
+    return std::move(comp_group);
+}
+
+} // namespace internal
+} // namespace skottie
diff --git a/modules/skottie/src/SkottieLayerEffect.cpp b/modules/skottie/src/SkottieLayerEffect.cpp
new file mode 100644
index 0000000..f3e9331
--- /dev/null
+++ b/modules/skottie/src/SkottieLayerEffect.cpp
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+#include "SkottiePriv.h"
+
+#include "SkJSON.h"
+#include "SkottieJson.h"
+#include "SkSGColor.h"
+#include "SkSGColorFilter.h"
+
+namespace skottie {
+namespace internal {
+
+namespace {
+
+sk_sp<sksg::RenderNode> AttachFillLayerEffect(const skjson::ArrayValue* jeffect_props,
+                                              AnimatorScope* ascope,
+                                              sk_sp<sksg::RenderNode> layer) {
+    if (!jeffect_props) return layer;
+
+    sk_sp<sksg::Color> color_node;
+
+    for (const skjson::ObjectValue* jprop : *jeffect_props) {
+        if (!jprop) continue;
+
+        switch (const auto ty = ParseDefault<int>((*jprop)["ty"], -1)) {
+        case 2: // color
+            color_node = AttachColor(*jprop, ascope, "v");
+            break;
+        default:
+            LOG("?? Ignoring unsupported fill effect poperty type: %d\n", ty);
+            break;
+        }
+    }
+
+    return color_node
+        ? sksg::ColorModeFilter::Make(std::move(layer), std::move(color_node), SkBlendMode::kSrcIn)
+        : nullptr;
+}
+
+} // namespace
+
+sk_sp<sksg::RenderNode> AnimationBuilder::attachLayerEffects(const skjson::ArrayValue& jeffects,
+                                                             AnimatorScope* ascope,
+                                                             sk_sp<sksg::RenderNode> layer) {
+    for (const skjson::ObjectValue* jeffect : jeffects) {
+        if (!jeffect) continue;
+
+        switch (const auto ty = ParseDefault<int>((*jeffect)["ty"], -1)) {
+        case 21: // Fill
+            layer = AttachFillLayerEffect((*jeffect)["ef"], ascope, std::move(layer));
+            break;
+        default:
+            LOG("?? Unsupported layer effect type: %d\n", ty);
+            break;
+        }
+    }
+
+    return layer;
+}
+
+} // namespace internal
+} // namespace skottie
diff --git a/modules/skottie/src/SkottiePrecompLayer.cpp b/modules/skottie/src/SkottiePrecompLayer.cpp
new file mode 100644
index 0000000..2266825
--- /dev/null
+++ b/modules/skottie/src/SkottiePrecompLayer.cpp
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+#include "SkottiePriv.h"
+
+#include "SkJSON.h"
+#include "SkottieAnimator.h"
+#include "SkottieJson.h"
+#include "SkottieValue.h"
+#include "SkMakeUnique.h"
+#include "SkSGRenderNode.h"
+#include "SkSGScene.h"
+#include "SkTLazy.h"
+
+namespace skottie {
+namespace internal {
+
+sk_sp<sksg::RenderNode> AnimationBuilder::attachPrecompLayer(const skjson::ObjectValue& jlayer,
+                                                             AnimatorScope* ascope) {
+    const skjson::ObjectValue* time_remap = jlayer["tm"];
+    const auto start_time = ParseDefault<float>(jlayer["st"], 0.0f),
+             stretch_time = ParseDefault<float>(jlayer["sr"], 1.0f);
+    const auto requires_time_mapping = !SkScalarNearlyEqual(start_time  , 0) ||
+                                       !SkScalarNearlyEqual(stretch_time, 1) ||
+                                       time_remap;
+
+    AnimatorScope local_animators;
+    auto precomp_layer = this->attachAssetRef(jlayer,
+                                              requires_time_mapping ? &local_animators : ascope,
+                                              &AnimationBuilder::attachComposition);
+
+    // Applies a bias/scale/remap t-adjustment to child animators.
+    class CompTimeMapper final : public sksg::GroupAnimator {
+    public:
+        CompTimeMapper(sksg::AnimatorList&& layer_animators, float time_bias, float time_scale)
+            : INHERITED(std::move(layer_animators))
+            , fTimeBias(time_bias)
+            , fTimeScale(time_scale) {}
+
+        void onTick(float t) override {
+            // When time remapping is active, |t| is driven externally.
+            if (fRemappedTime.isValid()) {
+                t = *fRemappedTime.get();
+            }
+
+            this->INHERITED::onTick((t + fTimeBias) * fTimeScale);
+        }
+
+        void remapTime(float t) { fRemappedTime.set(t); }
+
+    private:
+        const float    fTimeBias,
+                       fTimeScale;
+        SkTLazy<float> fRemappedTime;
+
+        using INHERITED = sksg::GroupAnimator;
+    };
+
+    if (requires_time_mapping) {
+        const auto t_bias  = -start_time,
+                   t_scale = sk_ieee_float_divide(1, stretch_time);
+        auto time_mapper = skstd::make_unique<CompTimeMapper>(std::move(local_animators),
+                                                              t_bias, t_scale);
+        if (time_remap) {
+            // The lambda below captures a raw pointer to the mapper object.  That should be safe,
+            // because both the lambda and the mapper are scoped/owned by ctx->fAnimators.
+            auto* raw_mapper = time_mapper.get();
+            auto  frame_rate = fFrameRate;
+            BindProperty<ScalarValue>(*time_remap, ascope,
+                    [raw_mapper, frame_rate](const ScalarValue& t) {
+                        raw_mapper->remapTime(t * frame_rate);
+                    });
+        }
+        ascope->push_back(std::move(time_mapper));
+    }
+
+    return precomp_layer;
+}
+
+} // namespace internal
+} // namespace skottie
diff --git a/modules/skottie/src/SkottiePriv.h b/modules/skottie/src/SkottiePriv.h
index fd9be2b..aa83183 100644
--- a/modules/skottie/src/SkottiePriv.h
+++ b/modules/skottie/src/SkottiePriv.h
@@ -28,7 +28,9 @@
 } // namespace skjson
 
 namespace sksg {
+class Color;
 class Matrix;
+class Path;
 class RenderNode;
 } // namespace sksg
 
@@ -105,6 +107,13 @@
     using INHERITED = SkNoncopyable;
 };
 
+// Shared helpers
+sk_sp<sksg::Color> AttachColor(const skjson::ObjectValue&, AnimatorScope*, const char prop_name[]);
+sk_sp<sksg::Path> AttachPath(const skjson::Value&, AnimatorScope*);
+sk_sp<sksg::Matrix> AttachMatrix(const skjson::ObjectValue&, AnimatorScope*, sk_sp<sksg::Matrix>);
+sk_sp<sksg::RenderNode> AttachOpacity(const skjson::ObjectValue&, AnimatorScope*,
+                                      sk_sp<sksg::RenderNode>);
+
 } // namespace internal
 } // namespace skottie
 
diff --git a/modules/skottie/src/SkottieShapeLayer.cpp b/modules/skottie/src/SkottieShapeLayer.cpp
new file mode 100644
index 0000000..3ad7a50
--- /dev/null
+++ b/modules/skottie/src/SkottieShapeLayer.cpp
@@ -0,0 +1,591 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+#include "SkottiePriv.h"
+
+#include "SkJSON.h"
+#include "SkottieAdapter.h"
+#include "SkottieAnimator.h"
+#include "SkottieJson.h"
+#include "SkottieValue.h"
+#include "SkPath.h"
+#include "SkSGColor.h"
+#include "SkSGDraw.h"
+#include "SkSGGeometryTransform.h"
+#include "SkSGGradient.h"
+#include "SkSGGroup.h"
+#include "SkSGMerge.h"
+#include "SkSGPath.h"
+#include "SkSGRect.h"
+#include "SkSGRoundEffect.h"
+#include "SkSGTransform.h"
+#include "SkSGTrimEffect.h"
+
+namespace skottie {
+namespace internal {
+
+namespace {
+
+sk_sp<sksg::GeometryNode> AttachPathGeometry(const skjson::ObjectValue& jpath,
+                                             AnimatorScope* ascope) {
+    return AttachPath(jpath["ks"], ascope);
+}
+
+sk_sp<sksg::GeometryNode> AttachRRectGeometry(const skjson::ObjectValue& jrect,
+                                              AnimatorScope* ascope) {
+    auto rect_node = sksg::RRect::Make();
+    auto adapter = sk_make_sp<RRectAdapter>(rect_node);
+
+    auto p_attached = BindProperty<VectorValue>(jrect["p"], ascope,
+        [adapter](const VectorValue& p) {
+            adapter->setPosition(ValueTraits<VectorValue>::As<SkPoint>(p));
+        });
+    auto s_attached = BindProperty<VectorValue>(jrect["s"], ascope,
+        [adapter](const VectorValue& s) {
+            adapter->setSize(ValueTraits<VectorValue>::As<SkSize>(s));
+        });
+    auto r_attached = BindProperty<ScalarValue>(jrect["r"], ascope,
+        [adapter](const ScalarValue& r) {
+            adapter->setRadius(SkSize::Make(r, r));
+        });
+
+    if (!p_attached && !s_attached && !r_attached) {
+        return nullptr;
+    }
+
+    return std::move(rect_node);
+}
+
+sk_sp<sksg::GeometryNode> AttachEllipseGeometry(const skjson::ObjectValue& jellipse,
+                                                AnimatorScope* ascope) {
+    auto rect_node = sksg::RRect::Make();
+    auto adapter = sk_make_sp<RRectAdapter>(rect_node);
+
+    auto p_attached = BindProperty<VectorValue>(jellipse["p"], ascope,
+        [adapter](const VectorValue& p) {
+            adapter->setPosition(ValueTraits<VectorValue>::As<SkPoint>(p));
+        });
+    auto s_attached = BindProperty<VectorValue>(jellipse["s"], ascope,
+        [adapter](const VectorValue& s) {
+            const auto sz = ValueTraits<VectorValue>::As<SkSize>(s);
+            adapter->setSize(sz);
+            adapter->setRadius(SkSize::Make(sz.width() / 2, sz.height() / 2));
+        });
+
+    if (!p_attached && !s_attached) {
+        return nullptr;
+    }
+
+    return std::move(rect_node);
+}
+
+sk_sp<sksg::GeometryNode> AttachPolystarGeometry(const skjson::ObjectValue& jstar,
+                                                 AnimatorScope* ascope) {
+    static constexpr PolyStarAdapter::Type gTypes[] = {
+        PolyStarAdapter::Type::kStar, // "sy": 1
+        PolyStarAdapter::Type::kPoly, // "sy": 2
+    };
+
+    const auto type = ParseDefault<size_t>(jstar["sy"], 0) - 1;
+    if (type >= SK_ARRAY_COUNT(gTypes)) {
+        LogJSON(jstar, "!! Unknown polystar type");
+        return nullptr;
+    }
+
+    auto path_node = sksg::Path::Make();
+    auto adapter = sk_make_sp<PolyStarAdapter>(path_node, gTypes[type]);
+
+    BindProperty<VectorValue>(jstar["p"], ascope,
+        [adapter](const VectorValue& p) {
+            adapter->setPosition(ValueTraits<VectorValue>::As<SkPoint>(p));
+        });
+    BindProperty<ScalarValue>(jstar["pt"], ascope,
+        [adapter](const ScalarValue& pt) {
+            adapter->setPointCount(pt);
+        });
+    BindProperty<ScalarValue>(jstar["ir"], ascope,
+        [adapter](const ScalarValue& ir) {
+            adapter->setInnerRadius(ir);
+        });
+    BindProperty<ScalarValue>(jstar["or"], ascope,
+        [adapter](const ScalarValue& otr) {
+            adapter->setOuterRadius(otr);
+        });
+    BindProperty<ScalarValue>(jstar["is"], ascope,
+        [adapter](const ScalarValue& is) {
+            adapter->setInnerRoundness(is);
+        });
+    BindProperty<ScalarValue>(jstar["os"], ascope,
+        [adapter](const ScalarValue& os) {
+            adapter->setOuterRoundness(os);
+        });
+    BindProperty<ScalarValue>(jstar["r"], ascope,
+        [adapter](const ScalarValue& r) {
+            adapter->setRotation(r);
+        });
+
+    return std::move(path_node);
+}
+
+sk_sp<sksg::Gradient> AttachGradient(const skjson::ObjectValue& jgrad, AnimatorScope* ascope) {
+    const skjson::ObjectValue* stops = jgrad["g"];
+    if (!stops)
+        return nullptr;
+
+    const auto stopCount = ParseDefault<int>((*stops)["p"], -1);
+    if (stopCount < 0)
+        return nullptr;
+
+    sk_sp<sksg::Gradient> gradient_node;
+    sk_sp<GradientAdapter> adapter;
+
+    if (ParseDefault<int>(jgrad["t"], 1) == 1) {
+        auto linear_node = sksg::LinearGradient::Make();
+        adapter = sk_make_sp<LinearGradientAdapter>(linear_node, stopCount);
+        gradient_node = std::move(linear_node);
+    } else {
+        auto radial_node = sksg::RadialGradient::Make();
+        adapter = sk_make_sp<RadialGradientAdapter>(radial_node, stopCount);
+
+        // TODO: highlight, angle
+        gradient_node = std::move(radial_node);
+    }
+
+    BindProperty<VectorValue>((*stops)["k"], ascope,
+        [adapter](const VectorValue& stops) {
+            adapter->setColorStops(stops);
+        });
+    BindProperty<VectorValue>(jgrad["s"], ascope,
+        [adapter](const VectorValue& s) {
+            adapter->setStartPoint(ValueTraits<VectorValue>::As<SkPoint>(s));
+        });
+    BindProperty<VectorValue>(jgrad["e"], ascope,
+        [adapter](const VectorValue& e) {
+            adapter->setEndPoint(ValueTraits<VectorValue>::As<SkPoint>(e));
+        });
+
+    return gradient_node;
+}
+
+sk_sp<sksg::PaintNode> AttachPaint(const skjson::ObjectValue& jpaint, AnimatorScope* ascope,
+                                   sk_sp<sksg::PaintNode> paint_node) {
+    if (paint_node) {
+        paint_node->setAntiAlias(true);
+
+        BindProperty<ScalarValue>(jpaint["o"], ascope,
+            [paint_node](const ScalarValue& o) {
+                // BM opacity is [0..100]
+                paint_node->setOpacity(o * 0.01f);
+        });
+    }
+
+    return paint_node;
+}
+
+sk_sp<sksg::PaintNode> AttachStroke(const skjson::ObjectValue& jstroke, AnimatorScope* ascope,
+                                    sk_sp<sksg::PaintNode> stroke_node) {
+    if (!stroke_node)
+        return nullptr;
+
+    stroke_node->setStyle(SkPaint::kStroke_Style);
+
+    BindProperty<ScalarValue>(jstroke["w"], ascope,
+        [stroke_node](const ScalarValue& w) {
+            stroke_node->setStrokeWidth(w);
+        });
+
+    stroke_node->setStrokeMiter(ParseDefault<SkScalar>(jstroke["ml"], 4.0f));
+
+    static constexpr SkPaint::Join gJoins[] = {
+        SkPaint::kMiter_Join,
+        SkPaint::kRound_Join,
+        SkPaint::kBevel_Join,
+    };
+    stroke_node->setStrokeJoin(gJoins[SkTMin<size_t>(ParseDefault<size_t>(jstroke["lj"], 1) - 1,
+                                                     SK_ARRAY_COUNT(gJoins) - 1)]);
+
+    static constexpr SkPaint::Cap gCaps[] = {
+        SkPaint::kButt_Cap,
+        SkPaint::kRound_Cap,
+        SkPaint::kSquare_Cap,
+    };
+    stroke_node->setStrokeCap(gCaps[SkTMin<size_t>(ParseDefault<size_t>(jstroke["lc"], 1) - 1,
+                                                   SK_ARRAY_COUNT(gCaps) - 1)]);
+
+    return stroke_node;
+}
+
+sk_sp<sksg::PaintNode> AttachColorFill(const skjson::ObjectValue& jfill, AnimatorScope* ascope) {
+    return AttachPaint(jfill, ascope, AttachColor(jfill, ascope, "c"));
+}
+
+sk_sp<sksg::PaintNode> AttachGradientFill(const skjson::ObjectValue& jfill, AnimatorScope* ascope) {
+    return AttachPaint(jfill, ascope, AttachGradient(jfill, ascope));
+}
+
+sk_sp<sksg::PaintNode> AttachColorStroke(const skjson::ObjectValue& jstroke,
+                                         AnimatorScope* ascope) {
+    return AttachStroke(jstroke, ascope, AttachPaint(jstroke, ascope,
+                                                     AttachColor(jstroke, ascope, "c")));
+}
+
+sk_sp<sksg::PaintNode> AttachGradientStroke(const skjson::ObjectValue& jstroke,
+                                            AnimatorScope* ascope) {
+    return AttachStroke(jstroke, ascope, AttachPaint(jstroke, ascope,
+                                                     AttachGradient(jstroke, ascope)));
+}
+
+sk_sp<sksg::Merge> Merge(std::vector<sk_sp<sksg::GeometryNode>>&& geos, sksg::Merge::Mode mode) {
+    std::vector<sksg::Merge::Rec> merge_recs;
+    merge_recs.reserve(geos.size());
+
+    for (const auto& geo : geos) {
+        merge_recs.push_back(
+            {std::move(geo), merge_recs.empty() ? sksg::Merge::Mode::kMerge : mode});
+    }
+
+    return sksg::Merge::Make(std::move(merge_recs));
+}
+
+std::vector<sk_sp<sksg::GeometryNode>> AttachMergeGeometryEffect(
+        const skjson::ObjectValue& jmerge, AnimatorScope*,
+        std::vector<sk_sp<sksg::GeometryNode>>&& geos) {
+    static constexpr sksg::Merge::Mode gModes[] = {
+        sksg::Merge::Mode::kMerge,      // "mm": 1
+        sksg::Merge::Mode::kUnion,      // "mm": 2
+        sksg::Merge::Mode::kDifference, // "mm": 3
+        sksg::Merge::Mode::kIntersect,  // "mm": 4
+        sksg::Merge::Mode::kXOR      ,  // "mm": 5
+    };
+
+    const auto mode = gModes[SkTMin<size_t>(ParseDefault<size_t>(jmerge["mm"], 1) - 1,
+                                            SK_ARRAY_COUNT(gModes) - 1)];
+
+    std::vector<sk_sp<sksg::GeometryNode>> merged;
+    merged.push_back(Merge(std::move(geos), mode));
+
+    return merged;
+}
+
+std::vector<sk_sp<sksg::GeometryNode>> AttachTrimGeometryEffect(
+        const skjson::ObjectValue& jtrim, AnimatorScope* ascope,
+        std::vector<sk_sp<sksg::GeometryNode>>&& geos) {
+
+    enum class Mode {
+        kMerged,   // "m": 1
+        kSeparate, // "m": 2
+    } gModes[] = { Mode::kMerged, Mode::kSeparate };
+
+    const auto mode = gModes[SkTMin<size_t>(ParseDefault<size_t>(jtrim["m"], 1) - 1,
+                                            SK_ARRAY_COUNT(gModes) - 1)];
+
+    std::vector<sk_sp<sksg::GeometryNode>> inputs;
+    if (mode == Mode::kMerged) {
+        inputs.push_back(Merge(std::move(geos), sksg::Merge::Mode::kMerge));
+    } else {
+        inputs = std::move(geos);
+    }
+
+    std::vector<sk_sp<sksg::GeometryNode>> trimmed;
+    trimmed.reserve(inputs.size());
+    for (const auto& i : inputs) {
+        const auto trimEffect = sksg::TrimEffect::Make(i);
+        trimmed.push_back(trimEffect);
+
+        const auto adapter = sk_make_sp<TrimEffectAdapter>(std::move(trimEffect));
+        BindProperty<ScalarValue>(jtrim["s"], ascope,
+            [adapter](const ScalarValue& s) {
+                adapter->setStart(s);
+            });
+        BindProperty<ScalarValue>(jtrim["e"], ascope,
+            [adapter](const ScalarValue& e) {
+                adapter->setEnd(e);
+            });
+        BindProperty<ScalarValue>(jtrim["o"], ascope,
+            [adapter](const ScalarValue& o) {
+                adapter->setOffset(o);
+            });
+    }
+
+    return trimmed;
+}
+
+std::vector<sk_sp<sksg::GeometryNode>> AttachRoundGeometryEffect(
+        const skjson::ObjectValue& jtrim, AnimatorScope* ascope,
+        std::vector<sk_sp<sksg::GeometryNode>>&& geos) {
+
+    std::vector<sk_sp<sksg::GeometryNode>> rounded;
+    rounded.reserve(geos.size());
+
+    for (const auto& g : geos) {
+        const auto roundEffect = sksg::RoundEffect::Make(std::move(g));
+        rounded.push_back(roundEffect);
+
+        BindProperty<ScalarValue>(jtrim["r"], ascope,
+            [roundEffect](const ScalarValue& r) {
+                roundEffect->setRadius(r);
+            });
+    }
+
+    return rounded;
+}
+
+using GeometryAttacherT = sk_sp<sksg::GeometryNode> (*)(const skjson::ObjectValue&, AnimatorScope*);
+static constexpr GeometryAttacherT gGeometryAttachers[] = {
+    AttachPathGeometry,
+    AttachRRectGeometry,
+    AttachEllipseGeometry,
+    AttachPolystarGeometry,
+};
+
+using PaintAttacherT = sk_sp<sksg::PaintNode> (*)(const skjson::ObjectValue&, AnimatorScope*);
+static constexpr PaintAttacherT gPaintAttachers[] = {
+    AttachColorFill,
+    AttachColorStroke,
+    AttachGradientFill,
+    AttachGradientStroke,
+};
+
+using GeometryEffectAttacherT =
+    std::vector<sk_sp<sksg::GeometryNode>> (*)(const skjson::ObjectValue&,
+                                               AnimatorScope*,
+                                               std::vector<sk_sp<sksg::GeometryNode>>&&);
+static constexpr GeometryEffectAttacherT gGeometryEffectAttachers[] = {
+    AttachMergeGeometryEffect,
+    AttachTrimGeometryEffect,
+    AttachRoundGeometryEffect,
+};
+
+enum class ShapeType {
+    kGeometry,
+    kGeometryEffect,
+    kPaint,
+    kGroup,
+    kTransform,
+};
+
+struct ShapeInfo {
+    const char* fTypeString;
+    ShapeType   fShapeType;
+    uint32_t    fAttacherIndex; // index into respective attacher tables
+};
+
+const ShapeInfo* FindShapeInfo(const skjson::ObjectValue& jshape) {
+    static constexpr ShapeInfo gShapeInfo[] = {
+        { "el", ShapeType::kGeometry      , 2 }, // ellipse   -> AttachEllipseGeometry
+        { "fl", ShapeType::kPaint         , 0 }, // fill      -> AttachColorFill
+        { "gf", ShapeType::kPaint         , 2 }, // gfill     -> AttachGradientFill
+        { "gr", ShapeType::kGroup         , 0 }, // group     -> Inline handler
+        { "gs", ShapeType::kPaint         , 3 }, // gstroke   -> AttachGradientStroke
+        { "mm", ShapeType::kGeometryEffect, 0 }, // merge     -> AttachMergeGeometryEffect
+        { "rc", ShapeType::kGeometry      , 1 }, // rrect     -> AttachRRectGeometry
+        { "rd", ShapeType::kGeometryEffect, 2 }, // round     -> AttachRoundGeometryEffect
+        { "sh", ShapeType::kGeometry      , 0 }, // shape     -> AttachPathGeometry
+        { "sr", ShapeType::kGeometry      , 3 }, // polystar  -> AttachPolyStarGeometry
+        { "st", ShapeType::kPaint         , 1 }, // stroke    -> AttachColorStroke
+        { "tm", ShapeType::kGeometryEffect, 1 }, // trim      -> AttachTrimGeometryEffect
+        { "tr", ShapeType::kTransform     , 0 }, // transform -> Inline handler
+    };
+
+    const skjson::StringValue* type = jshape["ty"];
+    if (!type) {
+        return nullptr;
+    }
+
+    const auto* info = bsearch(type->begin(),
+                               gShapeInfo,
+                               SK_ARRAY_COUNT(gShapeInfo),
+                               sizeof(ShapeInfo),
+                               [](const void* key, const void* info) {
+                                  return strcmp(static_cast<const char*>(key),
+                                                static_cast<const ShapeInfo*>(info)->fTypeString);
+                               });
+
+    return static_cast<const ShapeInfo*>(info);
+}
+
+struct GeometryEffectRec {
+    const skjson::ObjectValue& fJson;
+    GeometryEffectAttacherT    fAttach;
+};
+
+struct AttachShapeContext {
+    AttachShapeContext(AnimatorScope* ascope,
+                       std::vector<sk_sp<sksg::GeometryNode>>* geos,
+                       std::vector<GeometryEffectRec>* effects,
+                       size_t committedAnimators)
+        : fScope(ascope)
+        , fGeometryStack(geos)
+        , fGeometryEffectStack(effects)
+        , fCommittedAnimators(committedAnimators) {}
+
+    AnimatorScope*                          fScope;
+    std::vector<sk_sp<sksg::GeometryNode>>* fGeometryStack;
+    std::vector<GeometryEffectRec>*         fGeometryEffectStack;
+    size_t                                  fCommittedAnimators;
+};
+
+sk_sp<sksg::RenderNode> AttachShape(const skjson::ArrayValue* jshape,
+                                    AttachShapeContext* shapeCtx) {
+    if (!jshape)
+        return nullptr;
+
+    SkDEBUGCODE(const auto initialGeometryEffects = shapeCtx->fGeometryEffectStack->size();)
+
+    sk_sp<sksg::Group> shape_group = sksg::Group::Make();
+    sk_sp<sksg::RenderNode> shape_wrapper = shape_group;
+    sk_sp<sksg::Matrix> shape_matrix;
+
+    struct ShapeRec {
+        const skjson::ObjectValue& fJson;
+        const ShapeInfo&           fInfo;
+    };
+
+    // First pass (bottom->top):
+    //
+    //   * pick up the group transform and opacity
+    //   * push local geometry effects onto the stack
+    //   * store recs for next pass
+    //
+    std::vector<ShapeRec> recs;
+    for (size_t i = 0; i < jshape->size(); ++i) {
+        const skjson::ObjectValue* shape = (*jshape)[jshape->size() - 1 - i];
+        if (!shape) continue;
+
+        const auto* info = FindShapeInfo(*shape);
+        if (!info) {
+            LogJSON((*shape)["ty"], "!! Unknown shape");
+            continue;
+        }
+
+        recs.push_back({ *shape, *info });
+
+        switch (info->fShapeType) {
+        case ShapeType::kTransform:
+            if ((shape_matrix = AttachMatrix(*shape, shapeCtx->fScope, nullptr))) {
+                shape_wrapper = sksg::Transform::Make(std::move(shape_wrapper), shape_matrix);
+            }
+            shape_wrapper = AttachOpacity(*shape, shapeCtx->fScope, std::move(shape_wrapper));
+            break;
+        case ShapeType::kGeometryEffect:
+            SkASSERT(info->fAttacherIndex < SK_ARRAY_COUNT(gGeometryEffectAttachers));
+            shapeCtx->fGeometryEffectStack->push_back(
+                { *shape, gGeometryEffectAttachers[info->fAttacherIndex] });
+            break;
+        default:
+            break;
+        }
+    }
+
+    // Second pass (top -> bottom, after 2x reverse):
+    //
+    //   * track local geometry
+    //   * emit local paints
+    //
+    std::vector<sk_sp<sksg::GeometryNode>> geos;
+    std::vector<sk_sp<sksg::RenderNode  >> draws;
+    for (auto rec = recs.rbegin(); rec != recs.rend(); ++rec) {
+        switch (rec->fInfo.fShapeType) {
+        case ShapeType::kGeometry: {
+            SkASSERT(rec->fInfo.fAttacherIndex < SK_ARRAY_COUNT(gGeometryAttachers));
+            if (auto geo = gGeometryAttachers[rec->fInfo.fAttacherIndex](rec->fJson,
+                                                                         shapeCtx->fScope)) {
+                geos.push_back(std::move(geo));
+            }
+        } break;
+        case ShapeType::kGeometryEffect: {
+            // Apply the current effect and pop from the stack.
+            SkASSERT(rec->fInfo.fAttacherIndex < SK_ARRAY_COUNT(gGeometryEffectAttachers));
+            if (!geos.empty()) {
+                geos = gGeometryEffectAttachers[rec->fInfo.fAttacherIndex](rec->fJson,
+                                                                           shapeCtx->fScope,
+                                                                           std::move(geos));
+            }
+
+            SkASSERT(&shapeCtx->fGeometryEffectStack->back().fJson == &rec->fJson);
+            SkASSERT(shapeCtx->fGeometryEffectStack->back().fAttach ==
+                     gGeometryEffectAttachers[rec->fInfo.fAttacherIndex]);
+            shapeCtx->fGeometryEffectStack->pop_back();
+        } break;
+        case ShapeType::kGroup: {
+            AttachShapeContext groupShapeCtx(shapeCtx->fScope,
+                                             &geos,
+                                             shapeCtx->fGeometryEffectStack,
+                                             shapeCtx->fCommittedAnimators);
+            if (auto subgroup = AttachShape(rec->fJson["it"], &groupShapeCtx)) {
+                draws.push_back(std::move(subgroup));
+                SkASSERT(groupShapeCtx.fCommittedAnimators >= shapeCtx->fCommittedAnimators);
+                shapeCtx->fCommittedAnimators = groupShapeCtx.fCommittedAnimators;
+            }
+        } break;
+        case ShapeType::kPaint: {
+            SkASSERT(rec->fInfo.fAttacherIndex < SK_ARRAY_COUNT(gPaintAttachers));
+            auto paint = gPaintAttachers[rec->fInfo.fAttacherIndex](rec->fJson, shapeCtx->fScope);
+            if (!paint || geos.empty())
+                break;
+
+            auto drawGeos = geos;
+
+            // Apply all pending effects from the stack.
+            for (auto it = shapeCtx->fGeometryEffectStack->rbegin();
+                 it != shapeCtx->fGeometryEffectStack->rend(); ++it) {
+                drawGeos = it->fAttach(it->fJson, shapeCtx->fScope, std::move(drawGeos));
+            }
+
+            // If we still have multiple geos, reduce using 'merge'.
+            auto geo = drawGeos.size() > 1
+                ? Merge(std::move(drawGeos), sksg::Merge::Mode::kMerge)
+                : drawGeos[0];
+
+            SkASSERT(geo);
+            draws.push_back(sksg::Draw::Make(std::move(geo), std::move(paint)));
+            shapeCtx->fCommittedAnimators = shapeCtx->fScope->size();
+        } break;
+        default:
+            break;
+        }
+    }
+
+    // By now we should have popped all local geometry effects.
+    SkASSERT(shapeCtx->fGeometryEffectStack->size() == initialGeometryEffects);
+
+    // Push transformed local geometries to parent list, for subsequent paints.
+    for (const auto& geo : geos) {
+        shapeCtx->fGeometryStack->push_back(shape_matrix
+            ? sksg::GeometryTransform::Make(std::move(geo), shape_matrix)
+            : std::move(geo));
+    }
+
+    // Emit local draws reversed (bottom->top, per spec).
+    for (auto it = draws.rbegin(); it != draws.rend(); ++it) {
+        shape_group->addChild(std::move(*it));
+    }
+
+    return draws.empty() ? nullptr : shape_wrapper;
+}
+
+} // namespace
+
+sk_sp<sksg::RenderNode> AnimationBuilder::attachShapeLayer(const skjson::ObjectValue& layer,
+                                                           AnimatorScope* ascope) {
+    std::vector<sk_sp<sksg::GeometryNode>> geometryStack;
+    std::vector<GeometryEffectRec> geometryEffectStack;
+    AttachShapeContext shapeCtx(ascope, &geometryStack, &geometryEffectStack, ascope->size());
+    auto shapeNode = AttachShape(layer["shapes"], &shapeCtx);
+
+    // Trim uncommitted animators: AttachShape consumes effects on the fly, and greedily attaches
+    // geometries => at the end, we can end up with unused geometries, which are nevertheless alive
+    // due to attached animators.  To avoid this, we track committed animators and discard the
+    // orphans here.
+    SkASSERT(shapeCtx.fCommittedAnimators <= ascope->size());
+    ascope->resize(shapeCtx.fCommittedAnimators);
+
+    return shapeNode;
+}
+
+} // namespace internal
+} // namespace skottie