1 | /* |
2 | * Copyright 2019 Google Inc. |
3 | * |
4 | * Use of this source code is governed by a BSD-style license that can be |
5 | * found in the LICENSE file. |
6 | */ |
7 | |
8 | #include "modules/skottie/src/Layer.h" |
9 | |
10 | #include "modules/skottie/src/Camera.h" |
11 | #include "modules/skottie/src/Composition.h" |
12 | #include "modules/skottie/src/SkottieJson.h" |
13 | #include "modules/skottie/src/effects/Effects.h" |
14 | #include "modules/skottie/src/effects/MotionBlurEffect.h" |
15 | #include "modules/sksg/include/SkSGClipEffect.h" |
16 | #include "modules/sksg/include/SkSGDraw.h" |
17 | #include "modules/sksg/include/SkSGGroup.h" |
18 | #include "modules/sksg/include/SkSGMaskEffect.h" |
19 | #include "modules/sksg/include/SkSGMerge.h" |
20 | #include "modules/sksg/include/SkSGPaint.h" |
21 | #include "modules/sksg/include/SkSGPath.h" |
22 | #include "modules/sksg/include/SkSGRect.h" |
23 | #include "modules/sksg/include/SkSGRenderEffect.h" |
24 | #include "modules/sksg/include/SkSGRenderNode.h" |
25 | #include "modules/sksg/include/SkSGTransform.h" |
26 | |
27 | namespace skottie { |
28 | namespace internal { |
29 | |
30 | namespace { |
31 | |
32 | static constexpr int kNullLayerType = 3; |
33 | |
34 | struct MaskInfo { |
35 | SkBlendMode fBlendMode; // used when masking with layers/blending |
36 | sksg::Merge::Mode fMergeMode; // used when clipping |
37 | bool fInvertGeometry; |
38 | }; |
39 | |
40 | const MaskInfo* GetMaskInfo(char mode) { |
41 | static constexpr MaskInfo k_add_info = |
42 | { SkBlendMode::kSrcOver , sksg::Merge::Mode::kUnion , false }; |
43 | static constexpr MaskInfo k_int_info = |
44 | { SkBlendMode::kSrcIn , sksg::Merge::Mode::kIntersect , false }; |
45 | // AE 'subtract' is the same as 'intersect' + inverted geometry |
46 | // (draws the opacity-adjusted paint *outside* the shape). |
47 | static constexpr MaskInfo k_sub_info = |
48 | { SkBlendMode::kSrcIn , sksg::Merge::Mode::kIntersect , true }; |
49 | static constexpr MaskInfo k_dif_info = |
50 | { SkBlendMode::kDifference, sksg::Merge::Mode::kDifference, false }; |
51 | |
52 | switch (mode) { |
53 | case 'a': return &k_add_info; |
54 | case 'f': return &k_dif_info; |
55 | case 'i': return &k_int_info; |
56 | case 's': return &k_sub_info; |
57 | default: break; |
58 | } |
59 | |
60 | return nullptr; |
61 | } |
62 | |
63 | class MaskAdapter final : public AnimatablePropertyContainer { |
64 | public: |
65 | MaskAdapter(const skjson::ObjectValue& jmask, const AnimationBuilder& abuilder, SkBlendMode bm) |
66 | : fMaskPaint(sksg::Color::Make(SK_ColorBLACK)) { |
67 | fMaskPaint->setAntiAlias(true); |
68 | fMaskPaint->setBlendMode(bm); |
69 | |
70 | this->bind(abuilder, jmask["o" ], fOpacity); |
71 | |
72 | if (this->bind(abuilder, jmask["f" ], fFeather)) { |
73 | fMaskFilter = sksg::BlurImageFilter::Make(); |
74 | } |
75 | } |
76 | |
77 | bool hasEffect() const { |
78 | return !this->isStatic() |
79 | || fOpacity < 100 |
80 | || fFeather != SkV2{0,0}; |
81 | } |
82 | |
83 | sk_sp<sksg::RenderNode> makeMask(sk_sp<sksg::Path> mask_path) const { |
84 | auto mask = sksg::Draw::Make(std::move(mask_path), fMaskPaint); |
85 | |
86 | // Optional mask blur (feather). |
87 | return sksg::ImageFilterEffect::Make(std::move(mask), fMaskFilter); |
88 | } |
89 | |
90 | private: |
91 | void onSync() override { |
92 | fMaskPaint->setOpacity(fOpacity * 0.01f); |
93 | if (fMaskFilter) { |
94 | // Close enough to AE. |
95 | static constexpr SkScalar kFeatherToSigma = 0.38f; |
96 | fMaskFilter->setSigma({fFeather.x * kFeatherToSigma, |
97 | fFeather.y * kFeatherToSigma}); |
98 | } |
99 | } |
100 | |
101 | const sk_sp<sksg::PaintNode> fMaskPaint; |
102 | sk_sp<sksg::BlurImageFilter> fMaskFilter; // optional "feather" |
103 | |
104 | Vec2Value fFeather = {0,0}; |
105 | ScalarValue fOpacity = 100; |
106 | }; |
107 | |
108 | sk_sp<sksg::RenderNode> AttachMask(const skjson::ArrayValue* jmask, |
109 | const AnimationBuilder* abuilder, |
110 | sk_sp<sksg::RenderNode> childNode) { |
111 | if (!jmask) return childNode; |
112 | |
113 | struct MaskRecord { |
114 | sk_sp<sksg::Path> mask_path; // for clipping and masking |
115 | sk_sp<MaskAdapter> mask_adapter; // for masking |
116 | sksg::Merge::Mode merge_mode; // for clipping |
117 | }; |
118 | |
119 | SkSTArray<4, MaskRecord, true> mask_stack; |
120 | bool has_effect = false; |
121 | |
122 | for (const skjson::ObjectValue* m : *jmask) { |
123 | if (!m) continue; |
124 | |
125 | const skjson::StringValue* jmode = (*m)["mode" ]; |
126 | if (!jmode || jmode->size() != 1) { |
127 | abuilder->log(Logger::Level::kError, &(*m)["mode" ], "Invalid mask mode." ); |
128 | continue; |
129 | } |
130 | |
131 | const auto mode = *jmode->begin(); |
132 | if (mode == 'n') { |
133 | // "None" masks have no effect. |
134 | continue; |
135 | } |
136 | |
137 | const auto* mask_info = GetMaskInfo(mode); |
138 | if (!mask_info) { |
139 | abuilder->log(Logger::Level::kWarning, nullptr, "Unsupported mask mode: '%c'." , mode); |
140 | continue; |
141 | } |
142 | |
143 | auto mask_path = abuilder->attachPath((*m)["pt" ]); |
144 | if (!mask_path) { |
145 | abuilder->log(Logger::Level::kError, m, "Could not parse mask path." ); |
146 | continue; |
147 | } |
148 | |
149 | // "inv" is cumulative with mask info fInvertGeometry |
150 | const auto inverted = |
151 | (mask_info->fInvertGeometry != ParseDefault<bool>((*m)["inv" ], false)); |
152 | mask_path->setFillType(inverted ? SkPathFillType::kInverseWinding |
153 | : SkPathFillType::kWinding); |
154 | |
155 | const auto blend_mode = mask_stack.empty() ? SkBlendMode::kSrc |
156 | : mask_info->fBlendMode; |
157 | |
158 | auto mask_adapter = sk_make_sp<MaskAdapter>(*m, *abuilder, blend_mode); |
159 | abuilder->attachDiscardableAdapter(mask_adapter); |
160 | |
161 | has_effect |= mask_adapter->hasEffect(); |
162 | |
163 | |
164 | mask_stack.push_back({ std::move(mask_path), |
165 | std::move(mask_adapter), |
166 | mask_info->fMergeMode }); |
167 | } |
168 | |
169 | |
170 | if (mask_stack.empty()) |
171 | return childNode; |
172 | |
173 | // If the masks are fully opaque, we can clip. |
174 | if (!has_effect) { |
175 | sk_sp<sksg::GeometryNode> clip_node; |
176 | |
177 | if (mask_stack.count() == 1) { |
178 | // Single path -> just clip. |
179 | clip_node = std::move(mask_stack.front().mask_path); |
180 | } else { |
181 | // Multiple clip paths -> merge. |
182 | std::vector<sksg::Merge::Rec> merge_recs; |
183 | merge_recs.reserve(SkToSizeT(mask_stack.count())); |
184 | |
185 | for (auto& mask : mask_stack) { |
186 | const auto mode = merge_recs.empty() ? sksg::Merge::Mode::kMerge : mask.merge_mode; |
187 | merge_recs.push_back({std::move(mask.mask_path), mode}); |
188 | } |
189 | clip_node = sksg::Merge::Make(std::move(merge_recs)); |
190 | } |
191 | |
192 | return sksg::ClipEffect::Make(std::move(childNode), std::move(clip_node), true); |
193 | } |
194 | |
195 | // Complex masks (non-opaque or blurred) turn into a mask node stack. |
196 | sk_sp<sksg::RenderNode> maskNode; |
197 | if (mask_stack.count() == 1) { |
198 | // no group needed for single mask |
199 | const auto rec = mask_stack.front(); |
200 | maskNode = rec.mask_adapter->makeMask(std::move(rec.mask_path)); |
201 | } else { |
202 | std::vector<sk_sp<sksg::RenderNode>> masks; |
203 | masks.reserve(SkToSizeT(mask_stack.count())); |
204 | for (auto& rec : mask_stack) { |
205 | masks.push_back(rec.mask_adapter->makeMask(std::move(rec.mask_path))); |
206 | } |
207 | |
208 | maskNode = sksg::Group::Make(std::move(masks)); |
209 | } |
210 | |
211 | return sksg::MaskEffect::Make(std::move(childNode), std::move(maskNode)); |
212 | } |
213 | |
214 | class LayerController final : public Animator { |
215 | public: |
216 | LayerController(AnimatorScope&& layer_animators, |
217 | sk_sp<sksg::RenderNode> layer, |
218 | size_t tanim_count, float in, float out) |
219 | : fLayerAnimators(std::move(layer_animators)) |
220 | , fLayerNode(std::move(layer)) |
221 | , fTransformAnimatorsCount(tanim_count) |
222 | , fIn(in) |
223 | , fOut(out) {} |
224 | |
225 | protected: |
226 | StateChanged onSeek(float t) override { |
227 | // in/out may be inverted for time-reversed layers |
228 | const auto active = (t >= fIn && t < fOut) || (t > fOut && t <= fIn); |
229 | |
230 | bool changed = false; |
231 | if (fLayerNode) { |
232 | changed |= (fLayerNode->isVisible() != active); |
233 | fLayerNode->setVisible(active); |
234 | } |
235 | |
236 | // When active, dispatch ticks to all layer animators. |
237 | // When inactive, we must still dispatch ticks to the layer transform animators |
238 | // (active child layers depend on transforms being updated). |
239 | const auto dispatch_count = active ? fLayerAnimators.size() |
240 | : fTransformAnimatorsCount; |
241 | for (size_t i = 0; i < dispatch_count; ++i) { |
242 | changed |= fLayerAnimators[i]->seek(t); |
243 | } |
244 | |
245 | return changed; |
246 | } |
247 | |
248 | private: |
249 | const AnimatorScope fLayerAnimators; |
250 | const sk_sp<sksg::RenderNode> fLayerNode; |
251 | const size_t fTransformAnimatorsCount; |
252 | const float fIn, |
253 | fOut; |
254 | }; |
255 | |
256 | class MotionBlurController final : public Animator { |
257 | public: |
258 | explicit MotionBlurController(sk_sp<MotionBlurEffect> mbe) |
259 | : fMotionBlurEffect(std::move(mbe)) {} |
260 | |
261 | protected: |
262 | // When motion blur is present, time ticks are not passed to layer animators |
263 | // but to the motion blur effect. The effect then drives the animators/scene-graph |
264 | // during reval and render phases. |
265 | StateChanged onSeek(float t) override { |
266 | fMotionBlurEffect->setT(t); |
267 | return true; |
268 | } |
269 | |
270 | private: |
271 | const sk_sp<MotionBlurEffect> fMotionBlurEffect; |
272 | }; |
273 | |
274 | } // namespace |
275 | |
276 | LayerBuilder::LayerBuilder(const skjson::ObjectValue& jlayer) |
277 | : fJlayer(jlayer) |
278 | , fIndex(ParseDefault<int>(jlayer["ind" ], -1)) |
279 | , fParentIndex(ParseDefault<int>(jlayer["parent" ], -1)) |
280 | , fType(ParseDefault<int>(jlayer["ty" ], -1)) { |
281 | |
282 | if (this->isCamera() || ParseDefault<int>(jlayer["ddd" ], 0)) { |
283 | fFlags |= Flags::kIs3D; |
284 | } |
285 | } |
286 | |
287 | LayerBuilder::~LayerBuilder() = default; |
288 | |
289 | bool LayerBuilder::isCamera() const { |
290 | static constexpr int kCameraLayerType = 13; |
291 | |
292 | return fType == kCameraLayerType; |
293 | } |
294 | |
295 | sk_sp<sksg::Transform> LayerBuilder::buildTransform(const AnimationBuilder& abuilder, |
296 | CompositionBuilder* cbuilder) { |
297 | // Depending on the leaf node type, we treat the whole transform chain as either 2D or 3D. |
298 | const auto transform_chain_type = this->is3D() ? TransformType::k3D |
299 | : TransformType::k2D; |
300 | fLayerTransform = this->getTransform(abuilder, cbuilder, transform_chain_type); |
301 | |
302 | return fLayerTransform; |
303 | } |
304 | |
305 | sk_sp<sksg::Transform> LayerBuilder::getTransform(const AnimationBuilder& abuilder, |
306 | CompositionBuilder* cbuilder, |
307 | TransformType ttype) { |
308 | const auto cache_valid_mask = (1ul << ttype); |
309 | if (!(fFlags & cache_valid_mask)) { |
310 | // Set valid flag upfront to break cycles. |
311 | fFlags |= cache_valid_mask; |
312 | |
313 | const AnimationBuilder::AutoPropertyTracker apt(&abuilder, fJlayer); |
314 | AnimationBuilder::AutoScope ascope(&abuilder, std::move(fLayerScope)); |
315 | fTransformCache[ttype] = this->doAttachTransform(abuilder, cbuilder, ttype); |
316 | fLayerScope = ascope.release(); |
317 | fTransformAnimatorCount = fLayerScope.size(); |
318 | } |
319 | |
320 | return fTransformCache[ttype]; |
321 | } |
322 | |
323 | sk_sp<sksg::Transform> LayerBuilder::getParentTransform(const AnimationBuilder& abuilder, |
324 | CompositionBuilder* cbuilder, |
325 | TransformType ttype) { |
326 | if (auto* parent_builder = cbuilder->layerBuilder(fParentIndex)) { |
327 | // Explicit parent layer. |
328 | return parent_builder->getTransform(abuilder, cbuilder, ttype); |
329 | } |
330 | |
331 | if (ttype == TransformType::k3D) { |
332 | // During camera transform attachment, cbuilder->getCameraTransform() is null. |
333 | // This prevents camera->camera transform chain cycles. |
334 | SkASSERT(!this->isCamera() || !cbuilder->getCameraTransform()); |
335 | |
336 | // 3D transform chains are implicitly rooted onto the camera. |
337 | return cbuilder->getCameraTransform(); |
338 | } |
339 | |
340 | return nullptr; |
341 | } |
342 | |
343 | sk_sp<sksg::Transform> LayerBuilder::doAttachTransform(const AnimationBuilder& abuilder, |
344 | CompositionBuilder* cbuilder, |
345 | TransformType ttype) { |
346 | const skjson::ObjectValue* jtransform = fJlayer["ks" ]; |
347 | if (!jtransform) { |
348 | return nullptr; |
349 | } |
350 | |
351 | auto parent_transform = this->getParentTransform(abuilder, cbuilder, ttype); |
352 | |
353 | if (this->isCamera()) { |
354 | // parent_transform applies to the camera itself => it pre-composes inverted to the |
355 | // camera/view/adapter transform. |
356 | // |
357 | // T_camera' = T_camera x Inv(parent_transform) |
358 | // |
359 | return abuilder.attachCamera(fJlayer, |
360 | *jtransform, |
361 | sksg::Transform::MakeInverse(std::move(parent_transform)), |
362 | cbuilder->fSize); |
363 | } |
364 | |
365 | return this->is3D() |
366 | ? abuilder.attachMatrix3D(*jtransform, std::move(parent_transform)) |
367 | : abuilder.attachMatrix2D(*jtransform, std::move(parent_transform)); |
368 | } |
369 | |
370 | bool LayerBuilder::hasMotionBlur(const CompositionBuilder* cbuilder) const { |
371 | return cbuilder->fMotionBlurSamples > 1 |
372 | && cbuilder->fMotionBlurAngle > 0 |
373 | && ParseDefault(fJlayer["mb" ], false); |
374 | } |
375 | |
376 | sk_sp<sksg::RenderNode> LayerBuilder::buildRenderTree(const AnimationBuilder& abuilder, |
377 | CompositionBuilder* cbuilder, |
378 | const LayerBuilder* prev_layer) { |
379 | AnimationBuilder::LayerInfo layer_info = { |
380 | cbuilder->fSize, |
381 | ParseDefault<float>(fJlayer["ip" ], 0.0f), |
382 | ParseDefault<float>(fJlayer["op" ], 0.0f), |
383 | }; |
384 | if (SkScalarNearlyEqual(layer_info.fInPoint, layer_info.fOutPoint)) { |
385 | abuilder.log(Logger::Level::kError, nullptr, |
386 | "Invalid layer in/out points: %f/%f." , |
387 | layer_info.fInPoint, layer_info.fOutPoint); |
388 | return nullptr; |
389 | } |
390 | |
391 | const AnimationBuilder::AutoPropertyTracker apt(&abuilder, fJlayer); |
392 | |
393 | using LayerBuilder = |
394 | sk_sp<sksg::RenderNode> (AnimationBuilder::*)(const skjson::ObjectValue&, |
395 | AnimationBuilder::LayerInfo*) const; |
396 | |
397 | // AE is annoyingly inconsistent in how effects interact with layer transforms: depending on |
398 | // the layer type, effects are applied before or after the content is transformed. |
399 | // |
400 | // Empirically, pre-rendered layers (for some loose meaning of "pre-rendered") are in the |
401 | // former category (effects are subject to transformation), while the remaining types are in |
402 | // the latter. |
403 | enum : uint32_t { |
404 | kTransformEffects = 1, // The layer transform also applies to its effects. |
405 | }; |
406 | |
407 | static constexpr struct { |
408 | LayerBuilder fBuilder; |
409 | uint32_t fFlags; |
410 | } gLayerBuildInfo[] = { |
411 | { &AnimationBuilder::attachPrecompLayer, kTransformEffects }, // 'ty': 0 -> precomp |
412 | { &AnimationBuilder::attachSolidLayer , kTransformEffects }, // 'ty': 1 -> solid |
413 | { &AnimationBuilder::attachImageLayer , kTransformEffects }, // 'ty': 2 -> image |
414 | { &AnimationBuilder::attachNullLayer , 0 }, // 'ty': 3 -> null |
415 | { &AnimationBuilder::attachShapeLayer , 0 }, // 'ty': 4 -> shape |
416 | { &AnimationBuilder::attachTextLayer , 0 }, // 'ty': 5 -> text |
417 | }; |
418 | |
419 | if (SkToSizeT(fType) >= SK_ARRAY_COUNT(gLayerBuildInfo) && !this->isCamera()) { |
420 | return nullptr; |
421 | } |
422 | |
423 | // Switch to the layer animator scope (which at this point holds transform-only animators). |
424 | AnimationBuilder::AutoScope ascope(&abuilder, std::move(fLayerScope)); |
425 | |
426 | const auto is_hidden = ParseDefault<bool>(fJlayer["hd" ], false) || this->isCamera(); |
427 | const auto& build_info = gLayerBuildInfo[is_hidden ? kNullLayerType : SkToSizeT(fType)]; |
428 | |
429 | // Build the layer content fragment. |
430 | auto layer = (abuilder.*(build_info.fBuilder))(fJlayer, &layer_info); |
431 | |
432 | // Clip layers with explicit dimensions. |
433 | float w = 0, h = 0; |
434 | if (Parse<float>(fJlayer["w" ], &w) && Parse<float>(fJlayer["h" ], &h)) { |
435 | layer = sksg::ClipEffect::Make(std::move(layer), |
436 | sksg::Rect::Make(SkRect::MakeWH(w, h)), |
437 | true); |
438 | } |
439 | |
440 | // Optional layer mask. |
441 | layer = AttachMask(fJlayer["masksProperties" ], &abuilder, std::move(layer)); |
442 | |
443 | // Does the transform apply to effects also? |
444 | // (AE quirk: it doesn't - except for solid layers) |
445 | const auto transform_effects = (build_info.fFlags & kTransformEffects); |
446 | |
447 | // Attach the transform before effects, when needed. |
448 | if (fLayerTransform && !transform_effects) { |
449 | layer = sksg::TransformEffect::Make(std::move(layer), fLayerTransform); |
450 | } |
451 | |
452 | // Optional layer effects. |
453 | if (const skjson::ArrayValue* jeffects = fJlayer["ef" ]) { |
454 | layer = EffectBuilder(&abuilder, layer_info.fSize).attachEffects(*jeffects, |
455 | std::move(layer)); |
456 | } |
457 | |
458 | // Attach the transform after effects, when needed. |
459 | if (fLayerTransform && transform_effects) { |
460 | layer = sksg::TransformEffect::Make(std::move(layer), std::move(fLayerTransform)); |
461 | } |
462 | |
463 | // Optional layer styles. |
464 | if (const skjson::ArrayValue* jstyles = fJlayer["sy" ]) { |
465 | layer = EffectBuilder(&abuilder, layer_info.fSize).attachStyles(*jstyles, std::move(layer)); |
466 | } |
467 | |
468 | // Optional layer opacity. |
469 | // TODO: de-dupe this "ks" lookup with matrix above. |
470 | if (const skjson::ObjectValue* jtransform = fJlayer["ks" ]) { |
471 | layer = abuilder.attachOpacity(*jtransform, std::move(layer)); |
472 | } |
473 | |
474 | const auto has_animators = !abuilder.fCurrentAnimatorScope->empty(); |
475 | |
476 | sk_sp<Animator> controller = sk_make_sp<LayerController>(ascope.release(), |
477 | layer, |
478 | fTransformAnimatorCount, |
479 | layer_info.fInPoint, |
480 | layer_info.fOutPoint); |
481 | |
482 | // Optional motion blur. |
483 | if (layer && has_animators && this->hasMotionBlur(cbuilder)) { |
484 | // Wrap both the layer node and the controller. |
485 | auto motion_blur = MotionBlurEffect::Make(std::move(controller), std::move(layer), |
486 | cbuilder->fMotionBlurSamples, |
487 | cbuilder->fMotionBlurAngle, |
488 | cbuilder->fMotionBlurPhase); |
489 | controller = sk_make_sp<MotionBlurController>(motion_blur); |
490 | layer = std::move(motion_blur); |
491 | } |
492 | |
493 | abuilder.fCurrentAnimatorScope->push_back(std::move(controller)); |
494 | |
495 | // Stash the content tree in case it is needed for later mattes. |
496 | fContentTree = layer; |
497 | |
498 | if (ParseDefault<bool>(fJlayer["td" ], false)) { |
499 | // |layer| is a track matte. We apply it as a mask to the next layer. |
500 | return nullptr; |
501 | } |
502 | |
503 | // Optional matte. |
504 | size_t matte_mode; |
505 | if (prev_layer && Parse(fJlayer["tt" ], &matte_mode)) { |
506 | static constexpr sksg::MaskEffect::Mode gMatteModes[] = { |
507 | sksg::MaskEffect::Mode::kAlphaNormal, // tt: 1 |
508 | sksg::MaskEffect::Mode::kAlphaInvert, // tt: 2 |
509 | sksg::MaskEffect::Mode::kLumaNormal, // tt: 3 |
510 | sksg::MaskEffect::Mode::kLumaInvert, // tt: 4 |
511 | }; |
512 | |
513 | if (matte_mode > 0 && matte_mode <= SK_ARRAY_COUNT(gMatteModes)) { |
514 | // The current layer is masked with the previous layer *content*. |
515 | layer = sksg::MaskEffect::Make(std::move(layer), |
516 | prev_layer->fContentTree, |
517 | gMatteModes[matte_mode - 1]); |
518 | } else { |
519 | abuilder.log(Logger::Level::kError, nullptr, |
520 | "Unknown track matte mode: %zu\n" , matte_mode); |
521 | } |
522 | } |
523 | |
524 | // Finally, attach an optional blend mode. |
525 | // NB: blend modes are never applied to matte sources (layer content only). |
526 | return abuilder.attachBlendMode(fJlayer, std::move(layer)); |
527 | } |
528 | |
529 | } // namespace internal |
530 | } // namespace skottie |
531 | |