[skottie] Multi-frame image support

Extend the image asset provider API to support animated/multi-frame images.

Add a GM based on SkAnimCodecPlayer + animated public domain GIF
(source: https://giphy.com/explore/public-domain).

Bug: skia:
Change-Id: Iaa596e01a7626ca6574db1ebc90632f5a9a02bdc
Reviewed-on: https://skia-review.googlesource.com/159162
Commit-Queue: Florin Malita <fmalita@chromium.org>
Reviewed-by: Mike Reed <reed@google.com>
diff --git a/modules/skottie/gm/SkottieGM.cpp b/modules/skottie/gm/SkottieGM.cpp
index 229df62..6e88837 100644
--- a/modules/skottie/gm/SkottieGM.cpp
+++ b/modules/skottie/gm/SkottieGM.cpp
@@ -7,8 +7,10 @@
 
 #include "gm.h"
 #include "Resources.h"
+#include "SkAnimCodecPlayer.h"
 #include "SkAnimTimer.h"
 #include "SkColor.h"
+#include "SkMakeUnique.h"
 #include "Skottie.h"
 #include "SkottieProperty.h"
 
@@ -88,7 +90,6 @@
 DEF_GM(return new SkottieWebFontGM;)
 
 class SkottieColorizeGM : public skiagm::GM {
-public:
 protected:
     SkString onShortName() override {
         return SkString("skottie_colorize");
@@ -126,7 +127,6 @@
         return true;
     }
 
-protected:
     bool onHandleKey(SkUnichar uni) override {
         static constexpr SkColor kColors[] = {
             SK_ColorBLACK,
@@ -173,3 +173,81 @@
 };
 
 DEF_GM(return new SkottieColorizeGM;)
+
+class SkottieMultiFrameGM : public skiagm::GM {
+public:
+protected:
+    SkString onShortName() override {
+        return SkString("skottie_multiframe");
+    }
+
+    SkISize onISize() override {
+        return SkISize::Make(kSize, kSize);
+    }
+
+    void onOnceBeforeDraw() override {
+        if (auto stream = GetResourceAsStream("skottie/skottie_sample_multiframe.json")) {
+            fAnimation = Animation::Builder()
+                            .setResourceProvider(sk_make_sp<MultiFrameResourceProvider>())
+                            .make(stream.get());
+        }
+    }
+
+    void onDraw(SkCanvas* canvas) override {
+        if (!fAnimation) {
+            return;
+        }
+
+        auto dest = SkRect::MakeWH(kSize, kSize);
+        fAnimation->render(canvas, &dest);
+    }
+
+    bool onAnimate(const SkAnimTimer& timer) override {
+        if (!fAnimation) {
+            return false;
+        }
+
+        const auto duration = fAnimation->duration();
+        fAnimation->seek(std::fmod(timer.secs(), duration) / duration);
+        return true;
+    }
+
+private:
+    class MultiFrameImageAsset final : public skottie::ImageAsset {
+    public:
+        MultiFrameImageAsset() {
+            if (auto codec = SkCodec::MakeFromData(GetResourceAsData("images/flightAnim.gif"))) {
+                fPlayer = skstd::make_unique<SkAnimCodecPlayer>(std::move(codec));
+            }
+        }
+
+        bool isMultiFrame() override { return fPlayer ? fPlayer->duration() > 0 : false; }
+
+        sk_sp<SkImage> getFrame(float t) override {
+            if (!fPlayer) {
+                return nullptr;
+            }
+
+            fPlayer->seek(static_cast<uint32_t>(t * 1000));
+            return fPlayer->getFrame();
+        }
+
+    private:
+        std::unique_ptr<SkAnimCodecPlayer> fPlayer;
+    };
+
+    class MultiFrameResourceProvider final : public skottie::ResourceProvider {
+    public:
+        sk_sp<ImageAsset> loadImageAsset(const char[], const char[]) const override {
+            return sk_make_sp<MultiFrameImageAsset>();
+        }
+    };
+
+    static constexpr SkScalar kSize = 800;
+
+    sk_sp<Animation> fAnimation;
+
+    using INHERITED = skiagm::GM;
+};
+
+DEF_GM(return new SkottieMultiFrameGM;)
diff --git a/modules/skottie/include/Skottie.h b/modules/skottie/include/Skottie.h
index 9c6599d..f6a87d2 100644
--- a/modules/skottie/include/Skottie.h
+++ b/modules/skottie/include/Skottie.h
@@ -18,6 +18,7 @@
 
 class SkCanvas;
 class SkData;
+class SkImage;
 struct SkRect;
 class SkStream;
 
@@ -30,24 +31,50 @@
 class PropertyObserver;
 
 /**
+ * Image asset proxy interface.
+ */
+class SK_API ImageAsset : public SkRefCnt {
+public:
+    /**
+     * Returns true if the image asset is animated.
+     */
+    virtual bool isMultiFrame() = 0;
+
+    /**
+     * Returns the SkImage for a given frame.
+     *
+     * If the image asset is static, getImage() is only called once, at animation load time.
+     * Otherwise, this gets invoked every time the animation time is adjusted (on every seek).
+     *
+     * Embedders should cache and serve the same SkImage whenever possible, for efficiency.
+     *
+     * @param t   Frame time code, in seconds, relative to the image layer timeline origin
+     *            (in-point).
+     */
+    virtual sk_sp<SkImage> getFrame(float t) = 0;
+};
+
+/**
  * ResourceProvider allows Skottie embedders to control loading of external
  * Skottie resources -- e.g. images, fonts, nested animations.
  */
 class SK_API ResourceProvider : public SkRefCnt {
 public:
-    ResourceProvider() = default;
-    virtual ~ResourceProvider() = default;
-    ResourceProvider(const ResourceProvider&) = delete;
-    ResourceProvider& operator=(const ResourceProvider&) = delete;
-
     /**
-     * Load a resource (image, nested animation) specified by |path| + |name|, and
-     * return as an SkData.
+     * Load a generic resource (currently only nested animations) specified by |path| + |name|,
+     * and return as an SkData.
      */
     virtual sk_sp<SkData> load(const char resource_path[],
                                const char resource_name[]) const;
 
     /**
+     * Load an image asset specified by |path| + |name|, and returns the corresponding
+     * ImageAsset proxy.
+     */
+    virtual sk_sp<ImageAsset> loadImageAsset(const char resource_path[],
+                                             const char resource_name[]) const;
+
+    /**
      * Load an external font and return as SkData.
      *
      * @param name  font name    ("fName" Lottie property)
@@ -68,11 +95,6 @@
  */
 class SK_API Logger : public SkRefCnt {
 public:
-    Logger() = default;
-    virtual ~Logger() = default;
-    Logger(const Logger&) = delete;
-    Logger& operator=(const Logger&) = delete;
-
     enum class Level {
         kWarning,
         kError,
diff --git a/modules/skottie/src/Skottie.cpp b/modules/skottie/src/Skottie.cpp
index 41ece92..e4223cc 100644
--- a/modules/skottie/src/Skottie.cpp
+++ b/modules/skottie/src/Skottie.cpp
@@ -10,6 +10,7 @@
 #include "SkCanvas.h"
 #include "SkData.h"
 #include "SkFontMgr.h"
+#include "SkImage.h"
 #include "SkMakeUnique.h"
 #include "SkOSPath.h"
 #include "SkPaint.h"
@@ -245,6 +246,26 @@
     return nullptr;
 }
 
+sk_sp<ImageAsset> ResourceProvider::loadImageAsset(const char path[], const char name[]) const {
+    // Legacy API fallback.  TODO: remove after clients get updated.
+    class StaticImageAsset final : public ImageAsset {
+    public:
+        explicit StaticImageAsset(sk_sp<SkImage> img) : fImage(std::move(img)) {}
+
+        bool isMultiFrame() override { return false; }
+
+        sk_sp<SkImage> getFrame(float) override { return fImage; }
+
+    private:
+        sk_sp<SkImage> fImage;
+    };
+
+    auto image = SkImage::MakeFromEncoded(this->load(path, name));
+
+    return image ? sk_make_sp<StaticImageAsset>(std::move(image))
+                 : nullptr;
+}
+
 sk_sp<SkData> ResourceProvider::loadFont(const char[], const char[]) const {
     return nullptr;
 }
diff --git a/modules/skottie/src/SkottieLayer.cpp b/modules/skottie/src/SkottieLayer.cpp
index 1928ed1..b53b7f1 100644
--- a/modules/skottie/src/SkottieLayer.cpp
+++ b/modules/skottie/src/SkottieLayer.cpp
@@ -239,8 +239,8 @@
 
 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) const {
+    const std::function<sk_sp<sksg::RenderNode>(const skjson::ObjectValue&,
+                                                AnimatorScope*)>& func) const {
 
     const auto refId = ParseDefault<SkString>(jlayer["refId"], SkString());
     if (refId.isEmpty()) {
@@ -265,13 +265,14 @@
     }
 
     asset_info->fIsAttaching = true;
-    auto asset = (this->*attach_proc)(*asset_info->fAsset, ascope);
+    auto asset = func(*asset_info->fAsset, ascope);
     asset_info->fIsAttaching = false;
 
     return asset;
 }
 
 sk_sp<sksg::RenderNode> AnimationBuilder::attachSolidLayer(const skjson::ObjectValue& jlayer,
+                                                           const LayerInfo&,
                                                            AnimatorScope*) const {
     const auto size = SkSize::Make(ParseDefault<float>(jlayer["sw"], 0.0f),
                                    ParseDefault<float>(jlayer["sh"], 0.0f));
@@ -291,8 +292,8 @@
                             sksg::Color::Make(color));
 }
 
-sk_sp<sksg::RenderNode> AnimationBuilder::attachImageAsset(const skjson::ObjectValue& jimage,
-                                                           AnimatorScope*) const {
+const AnimationBuilder::ImageAssetInfo*
+AnimationBuilder::loadImageAsset(const skjson::ObjectValue& jimage) const {
     const skjson::StringValue* name = jimage["p"];
     const skjson::StringValue* path = jimage["u"];
     if (!name) {
@@ -302,41 +303,91 @@
     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;
+    if (auto* cached_info = fImageAssetCache.find(res_id)) {
+        return cached_info;
     }
 
-    const auto data = fResourceProvider->load(path_cstr, name_cstr);
-    if (!data) {
+    auto asset = fResourceProvider->loadImageAsset(path_cstr, name_cstr);
+    if (!asset) {
         this->log(Logger::Level::kError, nullptr,
-                  "Could not load image resource: %s/%s.", path_cstr, name_cstr);
+                  "Could not load image asset: %s/%s.", path_cstr, name_cstr);
         return nullptr;
     }
 
-    auto image = SkImage::MakeFromEncoded(data);
+    const auto size = SkISize::Make(ParseDefault<int>(jimage["w"], 0),
+                                    ParseDefault<int>(jimage["h"], 0));
+    return fImageAssetCache.set(res_id, { std::move(asset), size });
+}
+
+sk_sp<sksg::RenderNode> AnimationBuilder::attachImageAsset(const skjson::ObjectValue& jimage,
+                                                           const LayerInfo& layer_info,
+                                                           AnimatorScope* ascope) const {
+    const auto* asset_info = this->loadImageAsset(jimage);
+    if (!asset_info) {
+        return nullptr;
+    }
+    SkASSERT(asset_info->fAsset);
+
+    auto image = asset_info->fAsset->getFrame(0);
     if (!image) {
+        this->log(Logger::Level::kError, nullptr, "Could not load first image asset frame.");
         return nullptr;
     }
 
-    const auto width  = ParseDefault<int>(jimage["w"], image->width()),
-               height = ParseDefault<int>(jimage["h"], image->height());
+    auto image_node = sksg::Image::Make(image);
 
-    sk_sp<sksg::RenderNode> image_node = sksg::Image::Make(image);
-    if (width != image->width() || height != image->height()) {
-        image_node = sksg::Transform::Make(std::move(image_node),
-            SkMatrix::MakeScale(static_cast<float>(width)  / image->width(),
-                                static_cast<float>(height) / image->height()));
+    if (asset_info->fAsset->isMultiFrame()) {
+        class MultiFrameAnimator final : public sksg::Animator {
+        public:
+            MultiFrameAnimator(sk_sp<ImageAsset> asset, sk_sp<sksg::Image> image_node,
+                               float time_bias, float time_scale)
+                : fAsset(std::move(asset))
+                , fImageNode(std::move(image_node))
+                , fTimeBias(time_bias)
+                , fTimeScale(time_scale) {}
+
+            void onTick(float t) override {
+                fImageNode->setImage(fAsset->getFrame((t + fTimeBias) * fTimeScale));
+            }
+
+        private:
+            sk_sp<ImageAsset>     fAsset;
+            sk_sp<sksg::Image>    fImageNode;
+            float                 fTimeBias,
+                                  fTimeScale;
+        };
+
+        ascope->push_back(skstd::make_unique<MultiFrameAnimator>(asset_info->fAsset,
+                                                                 image_node,
+                                                                 layer_info.fInPoint,
+                                                                 1 / fFrameRate));
     }
 
-    return *fAssetCache.set(res_id, std::move(image_node));
+    const auto asset_size = SkISize::Make(
+            asset_info->fSize.width()  > 0 ? asset_info->fSize.width()  : image->width(),
+            asset_info->fSize.height() > 0 ? asset_info->fSize.height() : image->height());
+
+    if (asset_size == image->bounds().size()) {
+        // No resize needed.
+        return std::move(image_node);
+    }
+
+    return sksg::Transform::Make(std::move(image_node),
+        SkMatrix::MakeScale(static_cast<float>(asset_size.width())  / image->width(),
+                            static_cast<float>(asset_size.height()) / image->height()));
 }
 
 sk_sp<sksg::RenderNode> AnimationBuilder::attachImageLayer(const skjson::ObjectValue& jlayer,
+                                                           const LayerInfo& layer_info,
                                                            AnimatorScope* ascope) const {
-    return this->attachAssetRef(jlayer, ascope, &AnimationBuilder::attachImageAsset);
+    return this->attachAssetRef(jlayer, ascope,
+        [this, &layer_info] (const skjson::ObjectValue& jimage, AnimatorScope* ascope) {
+            return this->attachImageAsset(jimage, layer_info, ascope);
+        });
 }
 
 sk_sp<sksg::RenderNode> AnimationBuilder::attachNullLayer(const skjson::ObjectValue& layer,
+                                                          const LayerInfo&,
                                                           AnimatorScope*) const {
     // Null layers are used solely to drive dependent transforms,
     // but we use free-floating sksg::Matrices for that purpose.
@@ -410,9 +461,20 @@
                                                      AttachLayerContext* layerCtx) const {
     if (!jlayer) return nullptr;
 
+    const LayerInfo layer_info = {
+        ParseDefault<float>((*jlayer)["ip"], 0.0f),
+        ParseDefault<float>((*jlayer)["op"], 0.0f)
+    };
+    if (layer_info.fInPoint >= layer_info.fOutPoint) {
+        this->log(Logger::Level::kError, nullptr,
+                  "Invalid layer in/out points: %f/%f.", layer_info.fInPoint, layer_info.fOutPoint);
+        return nullptr;
+    }
+
     const AutoPropertyTracker apt(this, *jlayer);
 
     using LayerAttacher = sk_sp<sksg::RenderNode> (AnimationBuilder::*)(const skjson::ObjectValue&,
+                                                                        const LayerInfo&,
                                                                         AnimatorScope*) const;
     static constexpr LayerAttacher gLayerAttachers[] = {
         &AnimationBuilder::attachPrecompLayer,  // 'ty': 0
@@ -431,7 +493,7 @@
     AnimatorScope layer_animators;
 
     // Layer content.
-    auto layer = (this->*(gLayerAttachers[type]))(*jlayer, &layer_animators);
+    auto layer = (this->*(gLayerAttachers[type]))(*jlayer, layer_info, &layer_animators);
 
     // Clip layers with explicit dimensions.
     float w = 0, h = 0;
@@ -490,14 +552,13 @@
     };
 
     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)
+    if (!controller_node) {
         return nullptr;
+    }
 
     layerCtx->fScope->push_back(
-        skstd::make_unique<LayerController>(std::move(layer_animators), controller_node, in, out));
+        skstd::make_unique<LayerController>(std::move(layer_animators), controller_node,
+                                            layer_info.fInPoint, layer_info.fOutPoint));
 
     if (ParseDefault<bool>((*jlayer)["td"], false)) {
         // This layer is a matte.  We apply it as a mask to the next layer.
diff --git a/modules/skottie/src/SkottiePrecompLayer.cpp b/modules/skottie/src/SkottiePrecompLayer.cpp
index f229795..f110c2d 100644
--- a/modules/skottie/src/SkottiePrecompLayer.cpp
+++ b/modules/skottie/src/SkottiePrecompLayer.cpp
@@ -19,6 +19,7 @@
 namespace internal {
 
 sk_sp<sksg::RenderNode> AnimationBuilder::attachPrecompLayer(const skjson::ObjectValue& jlayer,
+                                                             const LayerInfo&,
                                                              AnimatorScope* ascope) const {
     const skjson::ObjectValue* time_remap = jlayer["tm"];
     // Empirically, a time mapper supersedes start/stretch.
@@ -31,7 +32,10 @@
     AnimatorScope local_animators;
     auto precomp_layer = this->attachAssetRef(jlayer,
                                               requires_time_mapping ? &local_animators : ascope,
-                                              &AnimationBuilder::attachComposition);
+                                              [this] (const skjson::ObjectValue& jcomp,
+                                                      AnimatorScope* ascope) {
+                                                  return this->attachComposition(jcomp, ascope);
+                                              });
 
     // Applies a bias/scale/remap t-adjustment to child animators.
     class CompTimeMapper final : public sksg::GroupAnimator {
diff --git a/modules/skottie/src/SkottiePriv.h b/modules/skottie/src/SkottiePriv.h
index 97ca6a3..741c36a 100644
--- a/modules/skottie/src/SkottiePriv.h
+++ b/modules/skottie/src/SkottiePriv.h
@@ -18,6 +18,8 @@
 #include "SkTypeface.h"
 #include "SkUTF.h"
 
+#include <functional>
+
 class SkFontMgr;
 
 namespace skjson {
@@ -77,6 +79,8 @@
 private:
     struct AttachLayerContext;
     struct AttachShapeContext;
+    struct ImageAssetInfo;
+    struct LayerInfo;
 
     void parseAssets(const skjson::ArrayValue*);
     void parseFonts (const skjson::ObjectValue* jfonts,
@@ -89,18 +93,26 @@
 
     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;
-    sk_sp<sksg::RenderNode> attachImageAsset(const skjson::ObjectValue&, AnimatorScope*) const;
+        const std::function<sk_sp<sksg::RenderNode>(const skjson::ObjectValue&,
+                                                    AnimatorScope* ctx)>&) const;
+    const ImageAssetInfo* loadImageAsset(const skjson::ObjectValue&) const;
+    sk_sp<sksg::RenderNode> attachImageAsset(const skjson::ObjectValue&, const LayerInfo&,
+                                             AnimatorScope*) const;
 
     sk_sp<sksg::RenderNode> attachNestedAnimation(const char* name, AnimatorScope* ascope) const;
 
-    sk_sp<sksg::RenderNode> attachImageLayer  (const skjson::ObjectValue&, AnimatorScope*) const;
-    sk_sp<sksg::RenderNode> attachNullLayer   (const skjson::ObjectValue&, AnimatorScope*) const;
-    sk_sp<sksg::RenderNode> attachPrecompLayer(const skjson::ObjectValue&, AnimatorScope*) const;
-    sk_sp<sksg::RenderNode> attachShapeLayer  (const skjson::ObjectValue&, AnimatorScope*) const;
-    sk_sp<sksg::RenderNode> attachSolidLayer  (const skjson::ObjectValue&, AnimatorScope*) const;
-    sk_sp<sksg::RenderNode> attachTextLayer   (const skjson::ObjectValue&, AnimatorScope*) const;
+    sk_sp<sksg::RenderNode> attachImageLayer  (const skjson::ObjectValue&, const LayerInfo&,
+                                               AnimatorScope*) const;
+    sk_sp<sksg::RenderNode> attachNullLayer   (const skjson::ObjectValue&, const LayerInfo&,
+                                               AnimatorScope*) const;
+    sk_sp<sksg::RenderNode> attachPrecompLayer(const skjson::ObjectValue&, const LayerInfo&,
+                                               AnimatorScope*) const;
+    sk_sp<sksg::RenderNode> attachShapeLayer  (const skjson::ObjectValue&, const LayerInfo&,
+                                               AnimatorScope*) const;
+    sk_sp<sksg::RenderNode> attachSolidLayer  (const skjson::ObjectValue&, const LayerInfo&,
+                                               AnimatorScope*) const;
+    sk_sp<sksg::RenderNode> attachTextLayer   (const skjson::ObjectValue&, const LayerInfo&,
+                                               AnimatorScope*) const;
 
     bool dispatchColorProperty(const sk_sp<sksg::Color>&) const;
     bool dispatchOpacityProperty(const sk_sp<sksg::OpacityEffect>&) const;
@@ -156,6 +168,11 @@
 
     mutable const char*        fPropertyObserverContext;
 
+    struct LayerInfo {
+        float fInPoint,
+              fOutPoint;
+    };
+
     struct AssetInfo {
         const skjson::ObjectValue* fAsset;
         mutable bool               fIsAttaching; // Used for cycle detection
@@ -170,14 +187,14 @@
         bool matches(const char family[], const char style[]) const;
     };
 
-    // TODO: consolidate these two?
-    using AssetMap   = SkTHashMap<SkString, AssetInfo>;
-    using AssetCache = SkTHashMap<SkString, sk_sp<sksg::RenderNode>>;
-    using FontMap    = SkTHashMap<SkString, FontInfo>;
+    struct ImageAssetInfo {
+        sk_sp<ImageAsset> fAsset;
+        SkISize           fSize;
+    };
 
-    AssetMap           fAssets;
-    FontMap            fFonts;
-    mutable AssetCache fAssetCache;
+    SkTHashMap<SkString, AssetInfo>              fAssets;
+    SkTHashMap<SkString, FontInfo>               fFonts;
+    mutable SkTHashMap<SkString, ImageAssetInfo> fImageAssetCache;
 
     using INHERITED = SkNoncopyable;
 };
diff --git a/modules/skottie/src/SkottieShapeLayer.cpp b/modules/skottie/src/SkottieShapeLayer.cpp
index dd37620..d0668d2 100644
--- a/modules/skottie/src/SkottieShapeLayer.cpp
+++ b/modules/skottie/src/SkottieShapeLayer.cpp
@@ -618,6 +618,7 @@
 }
 
 sk_sp<sksg::RenderNode> AnimationBuilder::attachShapeLayer(const skjson::ObjectValue& layer,
+                                                           const LayerInfo&,
                                                            AnimatorScope* ascope) const {
     std::vector<sk_sp<sksg::GeometryNode>> geometryStack;
     std::vector<GeometryEffectRec> geometryEffectStack;
diff --git a/modules/skottie/src/SkottieTextLayer.cpp b/modules/skottie/src/SkottieTextLayer.cpp
index 1f043b9..68929c4 100644
--- a/modules/skottie/src/SkottieTextLayer.cpp
+++ b/modules/skottie/src/SkottieTextLayer.cpp
@@ -236,6 +236,7 @@
 }
 
 sk_sp<sksg::RenderNode> AnimationBuilder::attachTextLayer(const skjson::ObjectValue& layer,
+                                                          const LayerInfo&,
                                                           AnimatorScope* ascope) const {
     // General text node format:
     // "t": {
diff --git a/modules/sksg/include/SkSGImage.h b/modules/sksg/include/SkSGImage.h
index dd524c3..b6aef47 100644
--- a/modules/sksg/include/SkSGImage.h
+++ b/modules/sksg/include/SkSGImage.h
@@ -23,9 +23,10 @@
 class Image final : public RenderNode {
 public:
     static sk_sp<Image> Make(sk_sp<SkImage> image) {
-        return image ? sk_sp<Image>(new Image(std::move(image))) : nullptr;
+        return sk_sp<Image>(new Image(std::move(image)));
     }
 
+    SG_ATTRIBUTE(Image,     sk_sp<SkImage> , fImage    )
     SG_ATTRIBUTE(Quality  , SkFilterQuality, fQuality  )
     SG_ATTRIBUTE(AntiAlias, bool           , fAntiAlias)
 
@@ -37,9 +38,9 @@
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
 
 private:
-    const sk_sp<SkImage> fImage;
-    SkFilterQuality      fQuality   = kLow_SkFilterQuality;
-    bool                 fAntiAlias = true;
+    sk_sp<SkImage>  fImage;
+    SkFilterQuality fQuality   = kLow_SkFilterQuality;
+    bool            fAntiAlias = true;
 
     typedef RenderNode INHERITED;
 };
diff --git a/modules/sksg/src/SkSGImage.cpp b/modules/sksg/src/SkSGImage.cpp
index 87c686d..59da5d7 100644
--- a/modules/sksg/src/SkSGImage.cpp
+++ b/modules/sksg/src/SkSGImage.cpp
@@ -15,6 +15,10 @@
 Image::Image(sk_sp<SkImage> image) : fImage(std::move(image)) {}
 
 void Image::onRender(SkCanvas* canvas, const RenderContext* ctx) const {
+    if (!fImage) {
+        return;
+    }
+
     SkPaint paint;
     paint.setAntiAlias(fAntiAlias);
     paint.setFilterQuality(fQuality);
@@ -27,7 +31,7 @@
 }
 
 SkRect Image::onRevalidate(InvalidationController*, const SkMatrix& ctm) {
-    return SkRect::Make(fImage->bounds());
+    return fImage ? SkRect::Make(fImage->bounds()) : SkRect::MakeEmpty();
 }
 
 } // namespace sksg
diff --git a/resources/images/flightAnim.gif b/resources/images/flightAnim.gif
new file mode 100644
index 0000000..7aaa327
--- /dev/null
+++ b/resources/images/flightAnim.gif
Binary files differ
diff --git a/resources/skottie/skottie_sample_multiframe.json b/resources/skottie/skottie_sample_multiframe.json
new file mode 100644
index 0000000..afd7849
--- /dev/null
+++ b/resources/skottie/skottie_sample_multiframe.json
@@ -0,0 +1,52 @@
+{
+   "v":"4.6.9",
+   "fr":60,
+   "ip":0,
+   "op":200,
+   "w":800,
+   "h":600,
+   "ddd":0,
+
+   "assets": [
+      {
+        "id": "image_0",
+        "p": "image_0.png",
+        "u": "images/",
+        "w": 600,
+        "h": 400
+      }
+   ],
+
+   "layers":[
+      {
+         "ddd":0,
+         "ind":1,
+         "ty":2,
+         "refId": "image_0",
+         "nm":"Custom Path 1",
+         "ao": 0,
+         "ip": 0,
+         "op": 200,
+         "st": 0,
+         "sr": 1,
+         "bm": 0,
+         "ks": {
+            "o": { "a":0, "k":100 },
+            "r": { "a":1, "k": [
+               { "t":   0, "s":  0, "e":  5 },
+               { "t":  50, "s":  5, "e": -5 },
+               { "t": 150, "s": -5, "e":  0 },
+               { "t": 200 }
+            ]},
+            "p": { "a":0, "k":[ 400, 300, 0 ] },
+            "a": { "a":0, "k":[ 300, 200, 0 ] },
+            "s": { "a":1, "k":[
+               { "t":   0, "s": [ 100, 100, 100 ], "e": [ 150, 150, 100 ] },
+               { "t": 100, "s": [ 150, 150, 100 ], "e": [ 100, 100, 100 ] },
+               { "t": 200 }
+            ]}
+         }
+
+      }
+   ]
+}