Reland: [skottie] Initial property setters

Introduce a PropertyObserver to receive property notifications for layer
and shape nodes.

Properties are communicated using strongly-typed "handles", which act
as impedance adapters between the AE/BM model and the internal Skottie
model.

Reviewed-by: Mike Reed <reed@google.com>
Change-Id: Id155076faa8595f6b4d81672559f01c2e0c7455a
TBR=
Reviewed-on: https://skia-review.googlesource.com/156626
Reviewed-by: Florin Malita <fmalita@chromium.org>
Commit-Queue: Florin Malita <fmalita@chromium.org>
diff --git a/modules/skottie/include/Skottie.h b/modules/skottie/include/Skottie.h
index ebe7408..e6e5960 100644
--- a/modules/skottie/include/Skottie.h
+++ b/modules/skottie/include/Skottie.h
@@ -26,6 +26,8 @@
 
 namespace skottie {
 
+class PropertyObserver;
+
 /**
  * ResourceProvider allows Skottie embedders to control loading of external
  * Skottie resources -- e.g. images, fonts, nested animations.
@@ -62,6 +64,9 @@
 
     class Builder final {
     public:
+        Builder();
+        ~Builder();
+
         struct Stats {
             float  fTotalLoadTimeMS  = 0, // Total animation instantiation time.
                    fJsonParseTimeMS  = 0, // Time spent building a JSON DOM.
@@ -88,6 +93,14 @@
         Builder& setFontManager(sk_sp<SkFontMgr>);
 
         /**
+         * Specify a PropertyObserver to receive callbacks during parsing.
+         *
+         * See SkottieProperty.h for more details.
+         *
+         */
+        Builder& setPropertyObserver(sk_sp<PropertyObserver>);
+
+        /**
          * Animation factories.
          */
         sk_sp<Animation> make(SkStream*);
@@ -97,6 +110,7 @@
     private:
         sk_sp<ResourceProvider> fResourceProvider;
         sk_sp<SkFontMgr>        fFontMgr;
+        sk_sp<PropertyObserver> fPropertyObserver;
         Stats                   fStats;
     };
 
diff --git a/modules/skottie/include/SkottieProperty.h b/modules/skottie/include/SkottieProperty.h
new file mode 100644
index 0000000..291b086
--- /dev/null
+++ b/modules/skottie/include/SkottieProperty.h
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+#ifndef SkottieProperty_DEFINED
+#define SkottieProperty_DEFINED
+
+#include "SkColor.h"
+#include "SkPoint.h"
+#include "SkRefCnt.h"
+
+#include <functional>
+
+class SkMatrix;
+
+namespace sksg {
+
+class Color;
+class OpacityEffect;
+
+} // namespace sksg
+
+namespace skottie {
+
+class ColorPropertyHandle;
+class OpacityPropertyHandle;
+class TransformPropertyHandle;
+
+/**
+ * A PropertyObserver can be used to track and manipulate certain properties of "interesting"
+ * Lottie nodes.
+ *
+ * When registered with an animation builder, PropertyObserver receives notifications for
+ * various properties of layer and shape nodes.  The |node_name| argument corresponds to the
+ * name ("nm") node property.
+ */
+class PropertyObserver : public SkRefCnt {
+public:
+    template <typename T>
+    using LazyHandle = std::function<std::unique_ptr<T>()>;
+
+    virtual void onColorProperty    (const char node_name[],
+                                     const LazyHandle<ColorPropertyHandle>&);
+    virtual void onOpacityProperty  (const char node_name[],
+                                     const LazyHandle<OpacityPropertyHandle>&);
+    virtual void onTransformProperty(const char node_name[],
+                                     const LazyHandle<TransformPropertyHandle>&);
+};
+
+namespace internal { class AnimationBuilder; }
+
+class ColorPropertyHandle final {
+public:
+    ~ColorPropertyHandle();
+
+    SkColor getColor() const;
+    void setColor(SkColor);
+
+private:
+    explicit ColorPropertyHandle(sk_sp<sksg::Color>);
+
+    friend class skottie::internal::AnimationBuilder;
+
+    const sk_sp<sksg::Color> fColor;
+};
+
+class OpacityPropertyHandle final {
+public:
+    ~OpacityPropertyHandle();
+
+    float getOpacity() const;
+    void setOpacity(float);
+
+private:
+    explicit OpacityPropertyHandle(sk_sp<sksg::OpacityEffect>);
+
+    friend class skottie::internal::AnimationBuilder;
+
+    const sk_sp<sksg::OpacityEffect> fOpacity;
+};
+
+class TransformAdapter;
+
+class TransformPropertyHandle final {
+public:
+    ~TransformPropertyHandle();
+
+    SkPoint getAnchorPoint() const;
+    void setAnchorPoint(const SkPoint&);
+
+    SkPoint getPosition() const;
+    void setPosition(const SkPoint&);
+
+    SkVector getScale() const;
+    void setScale(const SkVector&);
+
+    SkScalar getRotation() const;
+    void setRotation(SkScalar);
+
+    SkScalar getSkew() const;
+    void setSkew(SkScalar);
+
+    SkScalar getSkewAxis() const;
+    void setSkewAxis(SkScalar);
+
+    SkMatrix getTotalMatrix() const;
+
+private:
+    explicit TransformPropertyHandle(sk_sp<TransformAdapter>);
+
+    friend class skottie::internal::AnimationBuilder;
+
+    const sk_sp<TransformAdapter> fTransform;
+};
+
+} // namespace skottie
+
+#endif // SkottieProperty_DEFINED
diff --git a/modules/skottie/skottie.gni b/modules/skottie/skottie.gni
index 6d285ea..6cb5a38 100644
--- a/modules/skottie/skottie.gni
+++ b/modules/skottie/skottie.gni
@@ -7,7 +7,10 @@
 _src = get_path_info("src", "abspath")
 _include = get_path_info("include", "abspath")
 
-skia_skottie_public = [ "$_include/Skottie.h" ]
+skia_skottie_public = [
+  "$_include/Skottie.h",
+  "$_include/SkottieProperty.h",
+]
 
 skia_skottie_sources = [
   "$_src/Skottie.cpp",
@@ -20,6 +23,7 @@
   "$_src/SkottieLayerEffect.cpp",
   "$_src/SkottiePriv.h",
   "$_src/SkottiePrecompLayer.cpp",
+  "$_src/SkottieProperty.cpp",
   "$_src/SkottieShapeLayer.cpp",
   "$_src/SkottieTextLayer.cpp",
   "$_src/SkottieValue.cpp",
diff --git a/modules/skottie/src/Skottie.cpp b/modules/skottie/src/Skottie.cpp
index 5f5516c..9588e48 100644
--- a/modules/skottie/src/Skottie.cpp
+++ b/modules/skottie/src/Skottie.cpp
@@ -27,6 +27,7 @@
 #include "SkottieAdapter.h"
 #include "SkottieJson.h"
 #include "SkottiePriv.h"
+#include "SkottieProperty.h"
 #include "SkottieValue.h"
 
 #include <cmath>
@@ -83,7 +84,9 @@
                 adapter->setSkewAxis(sa);
             }, 0.0f);
 
-    return bound ? matrix : parentMatrix;
+    const auto dispatched = this->dispatchTransformProperty(adapter);
+
+    return (bound || dispatched) ? matrix : parentMatrix;
 }
 
 sk_sp<sksg::RenderNode> AnimationBuilder::attachOpacity(const skjson::ObjectValue& jtransform,
@@ -94,16 +97,15 @@
 
     auto opacityNode = sksg::OpacityEffect::Make(childNode);
 
-    if (!this->bindProperty<ScalarValue>(jtransform["o"], ascope,
+    const auto bound = this->bindProperty<ScalarValue>(jtransform["o"], ascope,
         [opacityNode](const ScalarValue& o) {
             // BM opacity is [0..100]
             opacityNode->setOpacity(o * 0.01f);
-        }, 100.0f)) {
-        // We can ignore static full opacity.
-        return childNode;
-    }
+        }, 100.0f);
+    const auto dispatched = this->dispatchOpacityProperty(opacityNode);
 
-    return std::move(opacityNode);
+    // We can ignore constant full opacity.
+    return (bound || dispatched) ? std::move(opacityNode) : childNode;
 }
 
 sk_sp<sksg::Path> AnimationBuilder::attachPath(const skjson::Value& jpath,
@@ -124,19 +126,23 @@
                                                  AnimatorScope* ascope,
                                                  const char prop_name[]) const {
     auto color_node = sksg::Color::Make(SK_ColorBLACK);
+
     this->bindProperty<VectorValue>(jcolor[prop_name], ascope,
         [color_node](const VectorValue& c) {
             color_node->setColor(ValueTraits<VectorValue>::As<SkColor>(c));
         });
+    this->dispatchColorProperty(color_node);
 
     return color_node;
 }
 
 AnimationBuilder::AnimationBuilder(sk_sp<ResourceProvider> rp, sk_sp<SkFontMgr> fontmgr,
+                                   sk_sp<PropertyObserver> pobserver,
                                    Animation::Builder::Stats* stats,
                                    float duration, float framerate)
     : fResourceProvider(std::move(rp))
     , fLazyFontMgr(std::move(fontmgr))
+    , fPropertyObserver(std::move(pobserver))
     , fStats(stats)
     , fDuration(duration)
     , fFrameRate(framerate) {}
@@ -165,6 +171,56 @@
     }
 }
 
+bool AnimationBuilder::dispatchColorProperty(const sk_sp<sksg::Color>& c) const {
+    bool dispatched = false;
+
+    if (fPropertyObserver) {
+        fPropertyObserver->onColorProperty(fPropertyObserverContext,
+            [&]() {
+                dispatched = true;
+                return std::unique_ptr<ColorPropertyHandle>(new ColorPropertyHandle(c));
+            });
+    }
+
+    return dispatched;
+}
+
+bool AnimationBuilder::dispatchOpacityProperty(const sk_sp<sksg::OpacityEffect>& o) const {
+    bool dispatched = false;
+
+    if (fPropertyObserver) {
+        fPropertyObserver->onOpacityProperty(fPropertyObserverContext,
+            [&]() {
+                dispatched = true;
+                return std::unique_ptr<OpacityPropertyHandle>(new OpacityPropertyHandle(o));
+            });
+    }
+
+    return dispatched;
+}
+
+bool AnimationBuilder::dispatchTransformProperty(const sk_sp<TransformAdapter>& t) const {
+    bool dispatched = false;
+
+    if (fPropertyObserver) {
+        fPropertyObserver->onTransformProperty(fPropertyObserverContext,
+            [&]() {
+                dispatched = true;
+                return std::unique_ptr<TransformPropertyHandle>(new TransformPropertyHandle(t));
+            });
+    }
+
+    return dispatched;
+}
+
+void AnimationBuilder::AutoPropertyTracker::updateContext(PropertyObserver* observer,
+                                                          const skjson::ObjectValue& obj) {
+
+    const skjson::StringValue* name = obj["nm"];
+
+    fBuilder->fPropertyObserverContext = name ? name->begin() : nullptr;
+}
+
 } // namespace internal
 
 sk_sp<SkData> ResourceProvider::load(const char[], const char[]) const {
@@ -175,6 +231,9 @@
     return nullptr;
 }
 
+Animation::Builder::Builder()  = default;
+Animation::Builder::~Builder() = default;
+
 Animation::Builder& Animation::Builder::setResourceProvider(sk_sp<ResourceProvider> rp) {
     fResourceProvider = std::move(rp);
     return *this;
@@ -185,6 +244,11 @@
     return *this;
 }
 
+Animation::Builder& Animation::Builder::setPropertyObserver(sk_sp<PropertyObserver> pobserver) {
+    fPropertyObserver = std::move(pobserver);
+    return *this;
+}
+
 sk_sp<Animation> Animation::Builder::make(SkStream* stream) {
     if (!stream->hasLength()) {
         // TODO: handle explicit buffering?
@@ -243,7 +307,7 @@
 
     SkASSERT(resolvedProvider);
     internal::AnimationBuilder builder(std::move(resolvedProvider), fFontMgr,
-                                       &fStats, duration, fps);
+                                       std::move(fPropertyObserver), &fStats, duration, fps);
     auto scene = builder.parse(json);
 
     const auto t2 = SkTime::GetMSecs();
diff --git a/modules/skottie/src/SkottieAdapter.cpp b/modules/skottie/src/SkottieAdapter.cpp
index 3c0fb54..b317c75 100644
--- a/modules/skottie/src/SkottieAdapter.cpp
+++ b/modules/skottie/src/SkottieAdapter.cpp
@@ -43,7 +43,7 @@
 TransformAdapter::TransformAdapter(sk_sp<sksg::Matrix> matrix)
     : fMatrixNode(std::move(matrix)) {}
 
-void TransformAdapter::apply() {
+SkMatrix TransformAdapter::totalMatrix() const {
     SkMatrix t = SkMatrix::MakeTrans(-fAnchorPoint.x(), -fAnchorPoint.y());
 
     t.postScale(fScale.x() / 100, fScale.y() / 100); // 100% based
@@ -51,7 +51,11 @@
     t.postTranslate(fPosition.x(), fPosition.y());
     // TODO: skew
 
-    fMatrixNode->setMatrix(t);
+    return t;
+}
+
+void TransformAdapter::apply() {
+    fMatrixNode->setMatrix(this->totalMatrix());
 }
 
 PolyStarAdapter::PolyStarAdapter(sk_sp<sksg::Path> wrapped_node, Type t)
diff --git a/modules/skottie/src/SkottieAdapter.h b/modules/skottie/src/SkottieAdapter.h
index 37dd077..f9a40fd 100644
--- a/modules/skottie/src/SkottieAdapter.h
+++ b/modules/skottie/src/SkottieAdapter.h
@@ -32,6 +32,9 @@
 namespace skottie {
 
 #define ADAPTER_PROPERTY(p_name, p_type, p_default) \
+    const p_type& get##p_name() const {             \
+        return f##p_name;                           \
+    }                                               \
     void set##p_name(const p_type& p) {             \
         if (p == f##p_name) return;                 \
         f##p_name = p;                              \
@@ -93,6 +96,8 @@
     ADAPTER_PROPERTY(Skew       , SkScalar, 0)
     ADAPTER_PROPERTY(SkewAxis   , SkScalar, 0)
 
+    SkMatrix totalMatrix() const;
+
 private:
     void apply();
 
diff --git a/modules/skottie/src/SkottieLayer.cpp b/modules/skottie/src/SkottieLayer.cpp
index b01acba..5924356 100644
--- a/modules/skottie/src/SkottieLayer.cpp
+++ b/modules/skottie/src/SkottieLayer.cpp
@@ -408,6 +408,8 @@
                                                      AttachLayerContext* layerCtx) const {
     if (!jlayer) return nullptr;
 
+    const AutoPropertyTracker apt(this, *jlayer);
+
     using LayerAttacher = sk_sp<sksg::RenderNode> (AnimationBuilder::*)(const skjson::ObjectValue&,
                                                                         AnimatorScope*) const;
     static constexpr LayerAttacher gLayerAttachers[] = {
diff --git a/modules/skottie/src/SkottiePriv.h b/modules/skottie/src/SkottiePriv.h
index 02ccbd9..b8c69b4 100644
--- a/modules/skottie/src/SkottiePriv.h
+++ b/modules/skottie/src/SkottiePriv.h
@@ -11,6 +11,7 @@
 #include "Skottie.h"
 
 #include "SkFontStyle.h"
+#include "SkottieProperty.h"
 #include "SkSGScene.h"
 #include "SkString.h"
 #include "SkTHash.h"
@@ -44,8 +45,8 @@
 
 class AnimationBuilder final : public SkNoncopyable {
 public:
-    AnimationBuilder(sk_sp<ResourceProvider>, sk_sp<SkFontMgr>, Animation::Builder::Stats*,
-                    float duration, float framerate);
+    AnimationBuilder(sk_sp<ResourceProvider>, sk_sp<SkFontMgr>, sk_sp<PropertyObserver>,
+                     Animation::Builder::Stats*, float duration, float framerate);
 
     std::unique_ptr<sksg::Scene> parse(const skjson::ObjectValue&);
 
@@ -77,6 +78,7 @@
 
 private:
     struct AttachLayerContext;
+    struct AttachShapeContext;
 
     void parseAssets(const skjson::ArrayValue*);
     void parseFonts (const skjson::ObjectValue* jfonts,
@@ -87,6 +89,7 @@
     sk_sp<sksg::RenderNode> attachLayerEffects(const skjson::ArrayValue& jeffects, AnimatorScope*,
                                                sk_sp<sksg::RenderNode>) const;
 
+    sk_sp<sksg::RenderNode> attachShape(const skjson::ArrayValue*, AttachShapeContext*) const;
     sk_sp<sksg::RenderNode> attachAssetRef(const skjson::ObjectValue&, AnimatorScope*,
         sk_sp<sksg::RenderNode>(AnimationBuilder::*)(const skjson::ObjectValue&,
                                                      AnimatorScope* ctx) const) const;
@@ -101,6 +104,10 @@
     sk_sp<sksg::RenderNode> attachSolidLayer  (const skjson::ObjectValue&, AnimatorScope*) const;
     sk_sp<sksg::RenderNode> attachTextLayer   (const skjson::ObjectValue&, AnimatorScope*) const;
 
+    bool dispatchColorProperty(const sk_sp<sksg::Color>&) const;
+    bool dispatchOpacityProperty(const sk_sp<sksg::OpacityEffect>&) const;
+    bool dispatchTransformProperty(const sk_sp<TransformAdapter>&) const;
+
     // Delay resolving the fontmgr until it is actually needed.
     struct LazyResolveFontMgr {
         LazyResolveFontMgr(sk_sp<SkFontMgr> fontMgr) : fFontMgr(std::move(fontMgr)) {}
@@ -119,12 +126,37 @@
         sk_sp<SkFontMgr> fFontMgr;
     };
 
+    class AutoPropertyTracker {
+    public:
+        AutoPropertyTracker(const AnimationBuilder* builder, const skjson::ObjectValue& obj)
+            : fBuilder(builder)
+            , fPrevContext(builder->fPropertyObserverContext) {
+            if (fBuilder->fPropertyObserver) {
+                this->updateContext(builder->fPropertyObserver.get(), obj);
+            }
+        }
+
+        ~AutoPropertyTracker() {
+            if (fBuilder->fPropertyObserver) {
+                fBuilder->fPropertyObserverContext = fPrevContext;
+            }
+        }
+    private:
+        void updateContext(PropertyObserver*, const skjson::ObjectValue&);
+
+        const AnimationBuilder* fBuilder;
+        const char*             fPrevContext;
+    };
+
     sk_sp<ResourceProvider>    fResourceProvider;
     LazyResolveFontMgr         fLazyFontMgr;
+    sk_sp<PropertyObserver>    fPropertyObserver;
     Animation::Builder::Stats* fStats;
     const float                fDuration,
                                fFrameRate;
 
+    mutable const char*        fPropertyObserverContext;
+
     struct AssetInfo {
         const skjson::ObjectValue* fAsset;
         mutable bool               fIsAttaching; // Used for cycle detection
diff --git a/modules/skottie/src/SkottieProperty.cpp b/modules/skottie/src/SkottieProperty.cpp
new file mode 100644
index 0000000..7ca35b9
--- /dev/null
+++ b/modules/skottie/src/SkottieProperty.cpp
@@ -0,0 +1,114 @@
+/*
+ * 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 "SkottieProperty.h"
+
+#include "SkottieAdapter.h"
+#include "SkSGColor.h"
+#include "SkSGOpacityEffect.h"
+
+namespace skottie {
+
+ColorPropertyHandle::ColorPropertyHandle(sk_sp<sksg::Color> color)
+    : fColor(std::move(color)) {
+    SkASSERT(fColor);
+}
+
+ColorPropertyHandle::~ColorPropertyHandle() = default;
+
+SkColor ColorPropertyHandle::getColor() const {
+    return fColor->getColor();
+}
+
+void ColorPropertyHandle::setColor(SkColor color) {
+    fColor->setColor(color);
+}
+
+OpacityPropertyHandle::OpacityPropertyHandle(sk_sp<sksg::OpacityEffect> opacity)
+    : fOpacity(std::move(opacity)) {
+    SkASSERT(fOpacity);
+}
+
+OpacityPropertyHandle::~OpacityPropertyHandle() = default;
+
+float OpacityPropertyHandle::getOpacity() const {
+    return fOpacity->getOpacity() * 100;
+}
+
+void OpacityPropertyHandle::setOpacity(float opacity) {
+    fOpacity->setOpacity(opacity / 100);
+}
+
+TransformPropertyHandle::TransformPropertyHandle(sk_sp<TransformAdapter> transform)
+    : fTransform(std::move(transform)) {
+    SkASSERT(fTransform);
+}
+
+TransformPropertyHandle::~TransformPropertyHandle() = default;
+
+SkPoint TransformPropertyHandle::getAnchorPoint() const {
+    return fTransform->getAnchorPoint();
+}
+
+void TransformPropertyHandle::setAnchorPoint(const SkPoint& ap) {
+    fTransform->setAnchorPoint(ap);
+}
+
+SkPoint TransformPropertyHandle::getPosition() const {
+    return fTransform->getPosition();
+}
+
+void TransformPropertyHandle::setPosition(const SkPoint& position) {
+    fTransform->setPosition(position);
+}
+
+SkVector TransformPropertyHandle::getScale() const {
+    return fTransform->getScale();
+}
+
+void TransformPropertyHandle::setScale(const SkVector& scale) {
+    fTransform->setScale(scale);
+}
+
+SkScalar TransformPropertyHandle::getRotation() const {
+    return fTransform->getRotation();
+}
+
+void TransformPropertyHandle::setRotation(SkScalar rotation) {
+    fTransform->setRotation(rotation);
+}
+
+SkScalar TransformPropertyHandle::getSkew() const {
+    return fTransform->getSkew();
+}
+
+void TransformPropertyHandle::setSkew(SkScalar skew) {
+    fTransform->setSkew(skew);
+}
+
+SkScalar TransformPropertyHandle::getSkewAxis() const {
+    return fTransform->getSkewAxis();
+}
+
+void TransformPropertyHandle::setSkewAxis(SkScalar sa) {
+    fTransform->setSkewAxis(sa);
+}
+
+SkMatrix TransformPropertyHandle::getTotalMatrix() const {
+    return fTransform->totalMatrix();
+}
+
+void PropertyObserver::onColorProperty(const char[],
+                                       const LazyHandle<ColorPropertyHandle>&) {}
+
+void PropertyObserver::onOpacityProperty(const char[],
+                                         const LazyHandle<OpacityPropertyHandle>&) {}
+
+void PropertyObserver::onTransformProperty(const char[],
+                                           const LazyHandle<TransformPropertyHandle>&) {}
+
+} // namespace skottie
diff --git a/modules/skottie/src/SkottieShapeLayer.cpp b/modules/skottie/src/SkottieShapeLayer.cpp
index b6e7a27..12f2695 100644
--- a/modules/skottie/src/SkottieShapeLayer.cpp
+++ b/modules/skottie/src/SkottieShapeLayer.cpp
@@ -430,26 +430,26 @@
     GeometryEffectAttacherT    fAttach;
 };
 
-struct AttachShapeContext {
-    AttachShapeContext(const AnimationBuilder* abuilder,
-                       AnimatorScope* ascope,
+} // namespace
+
+struct AnimationBuilder::AttachShapeContext {
+    AttachShapeContext(AnimatorScope* ascope,
                        std::vector<sk_sp<sksg::GeometryNode>>* geos,
                        std::vector<GeometryEffectRec>* effects,
                        size_t committedAnimators)
-        : fBuilder(abuilder)
-        , fScope(ascope)
+        : fScope(ascope)
         , fGeometryStack(geos)
         , fGeometryEffectStack(effects)
         , fCommittedAnimators(committedAnimators) {}
 
-    const AnimationBuilder*                 fBuilder;
     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* ctx) {
+sk_sp<sksg::RenderNode> AnimationBuilder::attachShape(const skjson::ArrayValue* jshape,
+                                                      AttachShapeContext* ctx) const {
     if (!jshape)
         return nullptr;
 
@@ -504,11 +504,13 @@
     std::vector<sk_sp<sksg::GeometryNode>> geos;
     std::vector<sk_sp<sksg::RenderNode  >> draws;
     for (auto rec = recs.rbegin(); rec != recs.rend(); ++rec) {
+        const AutoPropertyTracker apt(this, rec->fJson);
+
         switch (rec->fInfo.fShapeType) {
         case ShapeType::kGeometry: {
             SkASSERT(rec->fInfo.fAttacherIndex < SK_ARRAY_COUNT(gGeometryAttachers));
             if (auto geo = gGeometryAttachers[rec->fInfo.fAttacherIndex](rec->fJson,
-                                                                         ctx->fBuilder,
+                                                                         this,
                                                                          ctx->fScope)) {
                 geos.push_back(std::move(geo));
             }
@@ -518,7 +520,7 @@
             SkASSERT(rec->fInfo.fAttacherIndex < SK_ARRAY_COUNT(gGeometryEffectAttachers));
             if (!geos.empty()) {
                 geos = gGeometryEffectAttachers[rec->fInfo.fAttacherIndex](rec->fJson,
-                                                                           ctx->fBuilder,
+                                                                           this,
                                                                            ctx->fScope,
                                                                            std::move(geos));
             }
@@ -529,12 +531,11 @@
             ctx->fGeometryEffectStack->pop_back();
         } break;
         case ShapeType::kGroup: {
-            AttachShapeContext groupShapeCtx(ctx->fBuilder,
-                                             ctx->fScope,
+            AttachShapeContext groupShapeCtx(ctx->fScope,
                                              &geos,
                                              ctx->fGeometryEffectStack,
                                              ctx->fCommittedAnimators);
-            if (auto subgroup = AttachShape(rec->fJson["it"], &groupShapeCtx)) {
+            if (auto subgroup = this->attachShape(rec->fJson["it"], &groupShapeCtx)) {
                 draws.push_back(std::move(subgroup));
                 SkASSERT(groupShapeCtx.fCommittedAnimators >= ctx->fCommittedAnimators);
                 ctx->fCommittedAnimators = groupShapeCtx.fCommittedAnimators;
@@ -543,7 +544,7 @@
         case ShapeType::kPaint: {
             SkASSERT(rec->fInfo.fAttacherIndex < SK_ARRAY_COUNT(gPaintAttachers));
             auto paint = gPaintAttachers[rec->fInfo.fAttacherIndex](rec->fJson,
-                                                                    ctx->fBuilder,
+                                                                    this,
                                                                     ctx->fScope);
             if (!paint || geos.empty())
                 break;
@@ -553,7 +554,7 @@
             // Apply all pending effects from the stack.
             for (auto it = ctx->fGeometryEffectStack->rbegin();
                  it != ctx->fGeometryEffectStack->rend(); ++it) {
-                drawGeos = it->fAttach(it->fJson, ctx->fBuilder, ctx->fScope, std::move(drawGeos));
+                drawGeos = it->fAttach(it->fJson, this, ctx->fScope, std::move(drawGeos));
             }
 
             // If we still have multiple geos, reduce using 'merge'.
@@ -588,16 +589,17 @@
 
     sk_sp<sksg::Matrix> shape_matrix;
     if (jtransform) {
+        const AutoPropertyTracker apt(this, *jtransform);
+
         // This is tricky due to the interaction with ctx->fCommittedAnimators: we want any
         // animators related to tranform/opacity to be committed => they must be inserted in front
         // of the dangling/uncommitted ones.
         AnimatorScope local_scope;
 
-        if ((shape_matrix = ctx->fBuilder->attachMatrix(*jtransform, &local_scope, nullptr))) {
+        if ((shape_matrix = this->attachMatrix(*jtransform, &local_scope, nullptr))) {
             shape_wrapper = sksg::Transform::Make(std::move(shape_wrapper), shape_matrix);
         }
-        shape_wrapper = ctx->fBuilder->attachOpacity(*jtransform, &local_scope,
-                                                     std::move(shape_wrapper));
+        shape_wrapper = this->attachOpacity(*jtransform, &local_scope, std::move(shape_wrapper));
 
         ctx->fScope->insert(ctx->fScope->begin() + ctx->fCommittedAnimators,
                             std::make_move_iterator(local_scope.begin()),
@@ -615,14 +617,12 @@
     return shape_wrapper;
 }
 
-} // namespace
-
 sk_sp<sksg::RenderNode> AnimationBuilder::attachShapeLayer(const skjson::ObjectValue& layer,
                                                            AnimatorScope* ascope) const {
     std::vector<sk_sp<sksg::GeometryNode>> geometryStack;
     std::vector<GeometryEffectRec> geometryEffectStack;
-    AttachShapeContext shapeCtx(this, ascope, &geometryStack, &geometryEffectStack, ascope->size());
-    auto shapeNode = AttachShape(layer["shapes"], &shapeCtx);
+    AttachShapeContext shapeCtx(ascope, &geometryStack, &geometryEffectStack, ascope->size());
+    auto shapeNode = this->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
diff --git a/modules/skottie/src/SkottieTest.cpp b/modules/skottie/src/SkottieTest.cpp
index 1c4cc07..e3bd68b 100644
--- a/modules/skottie/src/SkottieTest.cpp
+++ b/modules/skottie/src/SkottieTest.cpp
@@ -5,13 +5,19 @@
  * found in the LICENSE file.
  */
 
+#include "SkMatrix.h"
 #include "Skottie.h"
+#include "SkottieProperty.h"
 #include "SkStream.h"
 
 #include "Test.h"
 
+#include <vector>
+
+using namespace skottie;
+
 DEF_TEST(Skottie_OssFuzz8956, reporter) {
-    static constexpr const char json[] =
+    static constexpr char json[] =
         "{\"v\":\" \",\"fr\":3,\"w\":4,\"h\":3,\"layers\":[{\"ty\": 1, \"sw\": 10, \"sh\": 10,"
             " \"sc\":\"#ffffff\", \"ks\":{\"o\":{\"a\": true, \"k\":"
             " [{\"t\": 0, \"s\": 0, \"e\": 1, \"i\": {\"x\":[]}}]}}}]}";
@@ -19,5 +25,117 @@
     SkMemoryStream stream(json, strlen(json));
 
     // Passes if parsing doesn't crash.
-    auto animation = skottie::Animation::Make(&stream);
+    auto animation = Animation::Make(&stream);
+}
+
+DEF_TEST(Skottie_Properties, reporter) {
+    static constexpr char json[] = R"({
+                                     "v": "5.2.1",
+                                     "w": 100,
+                                     "h": 100,
+                                     "fr": 1,
+                                     "ip": 0,
+                                     "op": 1,
+                                     "layers": [
+                                       {
+                                         "ty": 4,
+                                         "nm": "layer_0",
+                                         "ind": 0,
+                                         "ip": 0,
+                                         "op": 1,
+                                         "ks": {
+                                           "o": { "a": 0, "k": 50 }
+                                         },
+                                         "shapes": [
+                                           {
+                                             "ty": "el",
+                                             "nm": "geometry_0",
+                                             "p": { "a": 0, "k": [ 50, 50 ] },
+                                             "s": { "a": 0, "k": [ 50, 50 ] }
+                                           },
+                                           {
+                                             "ty": "fl",
+                                             "nm": "fill_0",
+                                             "c": { "a": 0, "k": [ 1, 0, 0] }
+                                           },
+                                           {
+                                             "ty": "tr",
+                                             "nm": "shape_transform_0",
+                                             "o": { "a": 0, "k": 100 },
+                                             "s": { "a": 0, "k": [ 50, 50 ] }
+                                           }
+                                         ]
+                                       }
+                                     ]
+                                   })";
+
+    class TestPropertyObserver final : public PropertyObserver {
+    public:
+        struct ColorInfo {
+            SkString node_name;
+            SkColor  color;
+        };
+
+        struct OpacityInfo {
+            SkString node_name;
+            float    opacity;
+        };
+
+        struct TransformInfo {
+            SkString node_name;
+            SkMatrix matrix;
+        };
+
+        void onColorProperty(const char node_name[],
+                const PropertyObserver::LazyHandle<ColorPropertyHandle>& lh) override {
+            fColors.push_back({SkString(node_name), lh()->getColor()});
+        }
+
+        void onOpacityProperty(const char node_name[],
+                const PropertyObserver::LazyHandle<OpacityPropertyHandle>& lh) override {
+            fOpacities.push_back({SkString(node_name), lh()->getOpacity()});
+        }
+
+        void onTransformProperty(const char node_name[],
+                const PropertyObserver::LazyHandle<TransformPropertyHandle>& lh) override {
+            fTransforms.push_back({SkString(node_name), lh()->getTotalMatrix()});
+        }
+
+        const std::vector<ColorInfo>& colors() const { return fColors; }
+        const std::vector<OpacityInfo>& opacities() const { return fOpacities; }
+        const std::vector<TransformInfo>& transforms() const { return fTransforms; }
+
+    private:
+        std::vector<ColorInfo>     fColors;
+        std::vector<OpacityInfo>   fOpacities;
+        std::vector<TransformInfo> fTransforms;
+    };
+
+    SkMemoryStream stream(json, strlen(json));
+    auto observer = sk_make_sp<TestPropertyObserver>();
+
+    auto animation = skottie::Animation::Builder()
+            .setPropertyObserver(observer)
+            .make(&stream);
+
+    REPORTER_ASSERT(reporter, animation);
+
+    const auto& colors = observer->colors();
+    REPORTER_ASSERT(reporter, colors.size() == 1);
+    REPORTER_ASSERT(reporter, colors[0].node_name.equals("fill_0"));
+    REPORTER_ASSERT(reporter, colors[0].color == 0xffff0000);
+
+    const auto& opacities = observer->opacities();
+    REPORTER_ASSERT(reporter, opacities.size() == 2);
+    REPORTER_ASSERT(reporter, opacities[0].node_name.equals("shape_transform_0"));
+    REPORTER_ASSERT(reporter, SkScalarNearlyEqual(opacities[0].opacity, 100));
+    REPORTER_ASSERT(reporter, opacities[1].node_name.equals("layer_0"));
+    REPORTER_ASSERT(reporter, SkScalarNearlyEqual(opacities[1].opacity, 50));
+
+    const auto& transforms = observer->transforms();
+    REPORTER_ASSERT(reporter, transforms.size() == 2);
+    REPORTER_ASSERT(reporter, transforms[0].node_name.equals("shape_transform_0"));
+    REPORTER_ASSERT(reporter, transforms[0].matrix == SkMatrix::MakeScale(0.5, 0.5));
+    REPORTER_ASSERT(reporter, transforms[1].node_name.equals("layer_0"));
+    REPORTER_ASSERT(reporter, transforms[1].matrix == SkMatrix::I());
 }