[skottie] Refactor keyframe encoding

For each Lottie keyframe, we currently store interpolation
*segments*:

 {
   t0, v0
   t1, v1
   cubic_mapper
 }

This is quite redundant:

 - kf(n).t1 == kf(n+1).t0 for all keyframes
 - kf(n).v1 == kf(n+1).v0 for all non-constant keyframes

Refactor to store single keyframe records:

  {
    t, v
    mapping
  }

To identify constant keyframes, since we no longer store
explicit hard stops, we now tag each record with a "mapping"
selector:

    0 -> constant keyframe
    1 -> linear keyframe
  > 1 -> cubic keyframe (adjusted cubic mapper index)


This reduces the storage size by 2/5, and yields overall cleaner
logic (as we're no longer back-filling info as we parse).

Also add a handful of unit tests to lock down limit semantics
(keyframe segments are left-inclusive/right-exclusive).

Change-Id: I3ab0e5568b83ab8536a7d326dbc07c4c455e978d
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/270450
Commit-Queue: Florin Malita <fmalita@chromium.org>
Reviewed-by: Mike Reed <reed@google.com>
diff --git a/modules/skottie/BUILD.gn b/modules/skottie/BUILD.gn
index 5bda984..12fcb6a 100644
--- a/modules/skottie/BUILD.gn
+++ b/modules/skottie/BUILD.gn
@@ -37,9 +37,7 @@
       public_configs = [ ":utils_config" ]
       configs += [ "../../:skia_private" ]
 
-      sources = [
-        "utils/SkottieUtils.cpp",
-      ]
+      sources = [ "utils/SkottieUtils.cpp" ]
       deps = [
         ":skottie",
         "../..:skia",
@@ -56,6 +54,7 @@
         ]
         sources = [
           "src/SkottieTest.cpp",
+          "tests/Keyframe.cpp",
         ]
 
         deps = [
@@ -89,9 +88,7 @@
           "../..:skia",
         ]
 
-        public_deps = [
-          ":skottie",
-        ]
+        public_deps = [ ":skottie" ]
       }
 
       source_set("tool") {
@@ -99,9 +96,7 @@
         testonly = true
 
         configs += [ "../..:skia_private" ]
-        sources = [
-          "src/SkottieTool.cpp",
-        ]
+        sources = [ "src/SkottieTool.cpp" ]
 
         deps = [
           "../..:flags",
diff --git a/modules/skottie/src/Animator.cpp b/modules/skottie/src/Animator.cpp
index afc2ddc..044bccd 100644
--- a/modules/skottie/src/Animator.cpp
+++ b/modules/skottie/src/Animator.cpp
@@ -15,6 +15,8 @@
 
 #include <vector>
 
+#define DUMP_KF_RECORDS 0
+
 namespace skottie {
 namespace internal {
 
@@ -49,215 +51,265 @@
 protected:
     KeyframeAnimatorBase() = default;
 
-    struct KeyframeRec {
-        float t0, t1;
-        int   vidx0, vidx1, // v0/v1 indices
-              cmidx;        // cubic map index
+    struct LERPInfo {
+        float    weight;       // vidx0..vidx1 weight [0..1]
+        uint32_t vidx0, vidx1; // value indices (values are stored externally)
 
-        bool contains(float t) const { return t0 <= t && t <= t1; }
         bool isConstant() const { return vidx0 == vidx1; }
-        bool isValid() const {
-            SkASSERT(t0 <= t1);
-            // Constant frames don't need/use t1 and vidx1.
-            return t0 < t1 || this->isConstant();
-        }
     };
 
-    const KeyframeRec& frame(float t) {
-        if (!fCachedRec || !fCachedRec->contains(t)) {
-            fCachedRec = findFrame(t);
+    // Main entry point: |t| -> LERPInfo
+    LERPInfo getLERPInfo(float t) const {
+        SkASSERT(!fKFs.empty());
+
+        if (t <= fKFs.front().t) {
+            // Constant/clamped segment.
+            return { 0, fKFs.front().v_idx, fKFs.front().v_idx };
         }
-        return *fCachedRec;
-    }
+        if (t >= fKFs.back().t) {
+            // Constant/clamped segment.
+            return { 0, fKFs.back().v_idx, fKFs.back().v_idx };
+        }
 
-    bool isEmpty() const { return fRecs.empty(); }
+        // Cache the current segment (most queries have good locality).
+        if (!fCurrentSegment.contains(t)) {
+            fCurrentSegment = this->find_segment(t);
+        }
+        SkASSERT(fCurrentSegment.contains(t));
 
-    float localT(const KeyframeRec& rec, float t) const {
-        SkASSERT(rec.isValid());
-        SkASSERT(!rec.isConstant());
-        SkASSERT(t > rec.t0 && t < rec.t1);
+        if (fCurrentSegment.kf0->mapping == KFRec::kConstantMapping) {
+            // Constant/hold segment.
+            return { 0, fCurrentSegment.kf0->v_idx, fCurrentSegment.kf0->v_idx };
+        }
 
-        auto lt = (t - rec.t0) / (rec.t1 - rec.t0);
-
-        return rec.cmidx < 0
-            ? lt
-            : fCubicMaps[SkToSizeT(rec.cmidx)].computeYFromX(lt);
+        return {
+            this->compute_weight(fCurrentSegment, t),
+            fCurrentSegment.kf0->v_idx,
+            fCurrentSegment.kf1->v_idx,
+        };
     }
 
     virtual int parseValue(const AnimationBuilder&, const skjson::Value&) = 0;
 
-    void parseKeyFrames(const AnimationBuilder& abuilder, const skjson::ArrayValue& jframes) {
-        // Logically, a keyframe is defined as a (t0, t1, v0, v1) tuple: a given value
-        // is interpolated in the [v0..v1] interval over the [t0..t1] time span.
+    bool parseKeyFrames(const AnimationBuilder& abuilder, const skjson::ArrayValue& jkfs) {
+        // Keyframe format:
         //
-        // There are three interestingly-different keyframe formats handled here.
+        // [                        // array of
+        //   {
+        //     "t": <float>         // keyframe time
+        //     "s": <T>             // keyframe value
+        //     "h": <bool>          // optional constant/hold keyframe marker
+        //     "i": [<float,float>] // optional "in" Bezier control point
+        //     "o": [<float,float>] // optional "out" Bezier control point
+        //   },
+        //   ...
+        // ]
         //
-        // 1) Legacy keyframe format
+        // Legacy keyframe format:
         //
-        //      - normal keyframes specify t0 ("t"), v0 ("s") and v1 ("e")
-        //      - last frame only specifies a t0
-        //      - t1[frame] == t0[frame + 1]
-        //      - the last entry (where we cannot determine t1) is ignored
+        // [                        // array of
+        //   {
+        //     "t": <float>         // keyframe time
+        //     "s": <T>             // keyframe start value
+        //     "e": <T>             // keyframe end value
+        //     "h": <bool>          // optional constant/hold keyframe marker (constant mapping)
+        //     "i": [<float,float>] // optional "in" Bezier control point (cubic mapping)
+        //     "o": [<float,float>] // optional "out" Bezier control point (cubic mapping)
+        //   },
+        //   ...
+        //   {
+        //     "t": <float>         // last keyframe only specifies a t
+        //                          // the value is prev. keyframe end value
+        //   }
+        // ]
         //
-        // 2) Regular (new) keyframe format
-        //
-        //      - all keyframes specify t0 ("t") and v0 ("s")
-        //      - t1[frame] == t0[frame + 1]
-        //      - v1[frame] == v0[frame + 1]
-        //      - the last entry (where we cannot determine t1/v1) is ignored
-        //
-        // 3) Text value keyframe format
-        //
-        //      - similar to case #2, all keyframes specify t0 & v0
-        //      - unlike case #2, all keyframes are assumed to be constant (v1 == v0),
-        //        and the last frame is not discarded (its t1 is assumed -> inf)
-        //
+        // Note: the legacy format contains duplicates, as normal frames are contiguous:
+        //       frame(n).e == frame(n+1).s
 
+        // Tracks previous cubic map parameters (for deduping).
         SkPoint prev_c0 = { 0, 0 },
-                prev_c1 = prev_c0;
+                prev_c1 = { 0, 0 };
 
-        fRecs.reserve(std::max<size_t>(jframes.size(), 1) - 1);
+        const auto parse_mapping = [&](const skjson::ObjectValue& jkf) {
+            if (ParseDefault(jkf["h"], false)) {
+                return KFRec::kConstantMapping;
+            }
 
-        for (const skjson::ObjectValue* jframe : jframes) {
-            if (!jframe) continue;
+            SkPoint c0, c1;
+            if (!Parse(jkf["o"], &c0) ||
+                !Parse(jkf["i"], &c1) ||
+                SkCubicMap::IsLinear(c0, c1)) {
+                return KFRec::kLinearMapping;
+            }
 
-            float t0;
-            if (!Parse<float>((*jframe)["t"], &t0))
-                continue;
+            // De-dupe sequential cubic mappers.
+            if (c0 != prev_c0 || c1 != prev_c1 || fCMs.empty()) {
+                fCMs.emplace_back(c0, c1);
+                prev_c0 = c0;
+                prev_c1 = c1;
+            }
 
-            const auto v0_idx = this->parseValue(abuilder, (*jframe)["s"]),
-                       v1_idx = this->parseValue(abuilder, (*jframe)["e"]);
+            SkASSERT(!fCMs.empty());
+            return SkToU32(fCMs.size()) - 1 + KFRec::kCubicIndexOffset;
+        };
 
-            if (!fRecs.empty()) {
-                if (fRecs.back().t1 >= t0) {
-                    abuilder.log(Logger::Level::kWarning, nullptr,
-                                 "Ignoring out-of-order key frame (t:%f < t:%f).",
-                                 t0, fRecs.back().t1);
-                    continue;
+        const auto parse_value = [&](const skjson::ObjectValue& jkf, size_t i) {
+            auto v_idx = this->parseValue(abuilder, jkf["s"]);
+
+            // A missing value is only OK for the last legacy KF
+            // (where it is pulled from prev KF 'end' value).
+            if (v_idx < 0 && i > 0 && i == jkfs.size() - 1) {
+                const skjson::ObjectValue* prev_kf = jkfs[i - 1];
+                SkASSERT(prev_kf);
+                v_idx = this->parseValue(abuilder, (*prev_kf)["e"]);
+            }
+
+            return v_idx;
+        };
+
+        fKFs.reserve(jkfs.size());
+
+        for (size_t i = 0; i < jkfs.size(); ++i) {
+            const skjson::ObjectValue* jkf = jkfs[i];
+            if (!jkf) {
+                return false;
+            }
+
+            float t;
+            if (!Parse<float>((*jkf)["t"], &t)) {
+                return false;
+            }
+
+            auto v_idx = parse_value(*jkf, i);
+            if (v_idx < 0) {
+                return false;
+            }
+
+            if (i > 0) {
+                auto& prev_kf = fKFs.back();
+
+                // Ts must be strictly monotonic.
+                if (t <= prev_kf.t) {
+                    return false;
                 }
 
-                // Back-fill t1 and v1 (if needed).
-                auto& prev = fRecs.back();
-                prev.t1 = t0;
-
-                // Previous keyframe did not specify an end value (case #2, #3).
-                if (prev.vidx1 < 0) {
-                    // If this frame has no v0, we're in case #3 (constant text value),
-                    // otherwise case #2 (v0 for current frame is the same as prev frame v1).
-                    prev.vidx1 = v0_idx < 0 ? prev.vidx0 : v0_idx;
+                // We can power-reduce the mapping of repeated values (implicitly constant).
+                if (SkToU32(v_idx) == prev_kf.v_idx) {
+                    prev_kf.mapping = KFRec::kConstantMapping;
                 }
             }
 
-            // Start value 's' is required.
-            if (v0_idx < 0)
-                continue;
-
-            if ((v1_idx < 0) && ParseDefault((*jframe)["h"], false)) {
-                // Constant keyframe ("h": true).
-                fRecs.push_back({t0, t0, v0_idx, v0_idx, -1 });
-                continue;
-            }
-
-            const auto cubic_mapper_index = [&]() -> int {
-                // Do we have non-linear control points?
-                SkPoint c0, c1;
-                if (!Parse((*jframe)["o"], &c0) ||
-                    !Parse((*jframe)["i"], &c1) ||
-                    SkCubicMap::IsLinear(c0, c1)) {
-                    // No need for a cubic mapper.
-                    return -1;
-                }
-
-                // De-dupe sequential cubic mappers.
-                if (c0 != prev_c0 || c1 != prev_c1) {
-                    fCubicMaps.emplace_back(c0, c1);
-                    prev_c0 = c0;
-                    prev_c1 = c1;
-                }
-
-                SkASSERT(!fCubicMaps.empty());
-                return SkToInt(fCubicMaps.size()) - 1;
-            };
-
-            fRecs.push_back({t0, t0, v0_idx, v1_idx, cubic_mapper_index()});
+            fKFs.push_back({t, SkToU32(v_idx), parse_mapping(*jkf)});
         }
 
-        if (!fRecs.empty()) {
-            auto& last = fRecs.back();
+        SkASSERT(fKFs.size() == jkfs.size());
+        fCMs.shrink_to_fit();
 
-            // If the last entry has only a v0, we're in case #3 - make it a constant frame.
-            if (last.vidx0 >= 0 && last.vidx1 < 0) {
-                last.vidx1 = last.vidx0;
-                last.t1 = last.t0;
-            }
-
-            // If we couldn't determine a valid t1 for the last frame, discard it
-            // (most likely the last frame entry for all 3 cases).
-            if (!last.isValid()) {
-                fRecs.pop_back();
-            }
+#if(DUMP_KF_RECORDS)
+        SkDEBUGF("Animator[%p], values: %lu, KF records: %zu\n",
+                 this, fKFs.back().v_idx + 1, fKFs.size());
+        for (const auto& kf : fKFs) {
+            SkDEBUGF("  { t: %1.3f, v_idx: %lu, mapping: %lu }\n", kf.t, kf.v_idx, kf.mapping);
         }
-
-        fRecs.shrink_to_fit();
-        fCubicMaps.shrink_to_fit();
-
-        SkASSERT(fRecs.empty() || fRecs.back().isValid());
+#endif
+        return true;
     }
 
 private:
-    const KeyframeRec* findFrame(float t) const {
-        SkASSERT(!fRecs.empty());
+    // We store one KFRec for each AE/Lottie keyframe.
+    struct KFRec {
+        float    t;
+        uint32_t v_idx;
+        uint32_t mapping; // Encodes the value interpolation in [KFRec_n .. KFRec_n+1):
+                          //   0 -> constant
+                          //   1 -> linear
+                          //   n -> cubic: fCMs[n-2]
 
-        auto f0 = &fRecs.front(),
-             f1 = &fRecs.back();
+        static constexpr uint32_t kConstantMapping  = 0;
+        static constexpr uint32_t kLinearMapping    = 1;
+        static constexpr uint32_t kCubicIndexOffset = 2;
+    };
 
-        SkASSERT(f0->isValid());
-        SkASSERT(f1->isValid());
+    // Two sequential KFRecs determine how the value varies within [kf0 .. kf1)
+    struct KFSegment {
+        const KFRec* kf0;
+        const KFRec* kf1;
 
-        if (t < f0->t0) {
-            return f0;
+        bool contains(float t) const {
+            SkASSERT(!!kf0 == !!kf1);
+            SkASSERT(!kf0 || kf1 == kf0 + 1);
+
+            return kf0 && kf0->t <= t && t < kf1->t;
         }
+    };
 
-        if (t > f1->t1) {
-            return f1;
-        }
+    // Find the KFSegment containing |t|.
+    KFSegment find_segment(float t) const {
+        SkASSERT(fKFs.size() > 1);
+        SkASSERT(t > fKFs.front().t);
+        SkASSERT(t < fKFs.back().t);
 
-        while (f0 != f1) {
-            SkASSERT(f0 < f1);
-            SkASSERT(t >= f0->t0 && t <= f1->t1);
+        auto kf0 = &fKFs.front(),
+             kf1 = &fKFs.back();
 
-            const auto f = f0 + (f1 - f0) / 2;
-            SkASSERT(f->isValid());
+        // Binary-search, until we reduce to sequential keyframes.
+        while (kf0 + 1 != kf1) {
+            SkASSERT(kf0 < kf1);
+            SkASSERT(kf0->t <= t && t < kf1->t);
 
-            if (t > f->t1) {
-                f0 = f + 1;
+            const auto mid_kf = kf0 + (kf1 - kf0) / 2;
+
+            if (t >= mid_kf->t) {
+                kf0 = mid_kf;
             } else {
-                f1 = f;
+                kf1 = mid_kf;
             }
         }
 
-        SkASSERT(f0 == f1);
-        SkASSERT(f0->contains(t));
-
-        return f0;
+        return {kf0, kf1};
     }
 
-    std::vector<KeyframeRec> fRecs;
-    std::vector<SkCubicMap>  fCubicMaps;
-    const KeyframeRec*       fCachedRec = nullptr;
+    // Given a |t| and a containing KFSegment, compute the local interpolation weight.
+    float compute_weight(const KFSegment& seg, float t) const {
+        SkASSERT(seg.contains(t));
+
+        // Linear weight.
+        auto w = (t - seg.kf0->t) / (seg.kf1->t - seg.kf0->t);
+
+        // Optional cubic mapper.
+        if (seg.kf0->mapping >= KFRec::kCubicIndexOffset) {
+            SkASSERT(seg.kf0->v_idx != seg.kf1->v_idx);
+            const auto mapper_index = SkToSizeT(seg.kf0->mapping - KFRec::kCubicIndexOffset);
+            w = fCMs[mapper_index].computeYFromX(w);
+        }
+
+        return w;
+    }
+
+    std::vector<KFRec>      fKFs; // Keyframe records, one per AE/Lottie keyframe.
+    std::vector<SkCubicMap> fCMs; // Optional cubic mappers (Bezier interpolation).
+    mutable KFSegment       fCurrentSegment = { nullptr, nullptr }; // Cached segment.
 };
 
 template <typename T>
 class KeyframeAnimator final : public KeyframeAnimatorBase {
 public:
     static sk_sp<KeyframeAnimator> Make(const AnimationBuilder& abuilder,
-                                        const skjson::ArrayValue* jv,
+                                        const skjson::ArrayValue* jkfs,
                                         T* target_value) {
-        if (!jv) return nullptr;
+        if (!jkfs || jkfs->size() < 1) {
+            return nullptr;
+        }
 
-        sk_sp<KeyframeAnimator> animator(new KeyframeAnimator(abuilder, *jv, target_value));
+        sk_sp<KeyframeAnimator> animator(new KeyframeAnimator(target_value));
 
-        return animator->isEmpty() ? nullptr : animator;
+        animator->fValues.reserve(jkfs->size());
+        if (!animator->parseKeyFrames(abuilder, *jkfs)) {
+            return nullptr;
+        }
+        animator->fValues.shrink_to_fit();
+
+        return animator;
     }
 
     bool isConstant() const {
@@ -266,18 +318,8 @@
     }
 
 private:
-    KeyframeAnimator(const AnimationBuilder& abuilder,
-                     const skjson::ArrayValue& jframes,
-                     T* target_value)
-        : fTarget(target_value) {
-        // Generally, each keyframe holds two values (start, end) and a cubic mapper. Except
-        // the last frame, which only holds a marker timestamp.  Then, the values series is
-        // contiguous (keyframe[i].end == keyframe[i + 1].start), and we dedupe them.
-        //   => we'll store (keyframes.size) values and (keyframe.size - 1) recs and cubic maps.
-        fValues.reserve(jframes.size());
-        this->parseKeyFrames(abuilder, jframes);
-        fValues.shrink_to_fit();
-    }
+    explicit KeyframeAnimator(T* target_value)
+        : fTarget(target_value) {}
 
     int parseValue(const AnimationBuilder& abuilder, const skjson::Value& jv) override {
         T val;
@@ -294,21 +336,16 @@
     }
 
     void onTick(float t) override {
-        const auto& rec = this->frame(t);
-        SkASSERT(rec.isValid());
+        const auto& lerp_info = this->getLERPInfo(t);
 
-        if (rec.isConstant() || t <= rec.t0) {
-            *fTarget = fValues[SkToSizeT(rec.vidx0)];
-            return;
-        } else if (t >= rec.t1) {
-            *fTarget = fValues[SkToSizeT(rec.vidx1)];
-            return;
+        if (lerp_info.isConstant()) {
+            *fTarget = fValues[SkToSizeT(lerp_info.vidx0)];
+        } else {
+            ValueTraits<T>::Lerp(fValues[lerp_info.vidx0],
+                                 fValues[lerp_info.vidx1],
+                                 lerp_info.weight,
+                                 fTarget);
         }
-
-        ValueTraits<T>::Lerp(fValues[SkToSizeT(rec.vidx0)],
-                             fValues[SkToSizeT(rec.vidx1)],
-                             this->localT(rec, t),
-                             fTarget);
     }
 
     std::vector<T> fValues;
diff --git a/modules/skottie/tests/Keyframe.cpp b/modules/skottie/tests/Keyframe.cpp
new file mode 100644
index 0000000..31ee792
--- /dev/null
+++ b/modules/skottie/tests/Keyframe.cpp
@@ -0,0 +1,136 @@
+/*
+ * Copyright 2020 Google Inc.
+ *
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+#include "modules/skottie/src/Animator.h"
+#include "modules/skottie/src/SkottiePriv.h"
+#include "modules/skottie/src/SkottieValue.h"
+#include "src/utils/SkJSON.h"
+#include "tests/Test.h"
+
+#include <cmath>
+
+using namespace skottie;
+using namespace skottie::internal;
+
+namespace  {
+
+template <typename T>
+class MockProperty final : public AnimatablePropertyContainer {
+public:
+    explicit MockProperty(const char* jprop) {
+        AnimationBuilder abuilder(nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
+                                  {100, 100}, 10, 1, 0);
+        skjson::DOM json_dom(jprop, strlen(jprop));
+
+        fDidBind = this->bind(abuilder, json_dom.root(), &fValue);
+    }
+
+    operator bool() const { return fDidBind; }
+
+    const T& operator()(float t) { this->tick(t); return fValue; }
+
+private:
+    void onSync() override {}
+
+    T     fValue = T();
+    bool  fDidBind;
+};
+
+}
+
+DEF_TEST(Skottie_Keyframe, reporter) {
+    {
+        MockProperty<ScalarValue> prop(R"({})");
+        REPORTER_ASSERT(reporter, !prop);
+    }
+    {
+        MockProperty<ScalarValue> prop(R"({ "a": 1, "k": [] })");
+        REPORTER_ASSERT(reporter, !prop);
+    }
+    {
+        // New style
+        MockProperty<ScalarValue> prop(R"({
+                                         "a": 1,
+                                         "k": [
+                                           { "t":  1, "s": 1 },
+                                           { "t":  2, "s": 2 },
+                                           { "t":  3, "s": 4 }
+                                         ]
+                                       })");
+        REPORTER_ASSERT(reporter, prop);
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop( -1), 1));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(  0), 1));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(  1), 1));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(1.5), 1.5f));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(  2), 2));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(2.5), 3));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(  3), 4));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(  4), 4));
+    }
+    {
+        // New style hold (hard stops)
+        MockProperty<ScalarValue> prop(R"({
+                                         "a": 1,
+                                         "k": [
+                                           { "t":  1, "s": 1, "h": true },
+                                           { "t":  2, "s": 2, "h": true },
+                                           { "t":  3, "s": 4, "h": true }
+                                         ]
+                                       })");
+        REPORTER_ASSERT(reporter, prop);
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(0  ), 1));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(1  ), 1));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(1.5), 1));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(std::nextafter(2.f, 0.f)), 1));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(2  ), 2));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(2.5), 2));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(std::nextafter(3.f, 0.f)), 2));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(3  ), 4));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(4  ), 4));
+    }
+    {
+        // Legacy style
+        MockProperty<ScalarValue> prop(R"({
+                                         "a": 1,
+                                         "k": [
+                                           { "t":  1, "s": 1, "e": 2 },
+                                           { "t":  2, "s": 2, "e": 4 },
+                                           { "t":  3 }
+                                         ]
+                                       })");
+        REPORTER_ASSERT(reporter, prop);
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(-1), 1));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop( 0), 1));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop( 1  ), 1));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop( 1.5), 1.5f));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop( 2  ), 2));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop( 2.5), 3));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop( 3  ), 4));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop( 4  ), 4));
+    }
+    {
+        // Legacy style hold (hard stops)
+        MockProperty<ScalarValue> prop(R"({
+                                         "a": 1,
+                                         "k": [
+                                           { "t":  1, "s": 1, "e": 2, "h": true },
+                                           { "t":  2, "s": 2, "e": 4, "h": true },
+                                           { "t":  3 }
+                                         ]
+                                       })");
+        REPORTER_ASSERT(reporter, prop);
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(0  ), 1));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(1  ), 1));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(1.5), 1));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(std::nextafter(2.f, 0.f)), 1));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(2  ), 2));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(2.5), 2));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(std::nextafter(3.f, 0.f)), 2));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(3  ), 4));
+        REPORTER_ASSERT(reporter, SkScalarNearlyEqual(prop(4  ), 4));
+    }
+}