[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));
+ }
+}