// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
//
// How dynamic assets are handled:
//
// Terminology:
// "Position" - A physical location on the screen where a dynamic asset appears.
// Its identifier is arbitrary, opaque, and embedded in the dynamic asset's id.
//
// "Index"- There shall be 1 or more assets assigned to each position. For
// example, if an animation has a cross-fade transition from image 1 to image 2,
// there may be 2 dynamic assets in the animation that share the same position.
// However, their indices will be different. Example:
// "_CrOS_Photo_PositionA_1" (Index 1 Position A)
// "_CrOS_Photo_PositionA_2" (Index 2 Position A)
// ...
//
// Now we'll step through an example with 2 positions and 2 assets per position:
// "_CrOS_Photo_PositionA_1"
// "_CrOS_Photo_PositionA_2"
// "_CrOS_Photo_PositionB_1"
// "_CrOS_Photo_PositionB_2"
//
// On the very first frame, the provider assigns a topic to each asset in the
// model using all topics available in the model:
// "_CrOS_Photo_PositionA_1" -> "TopicA"
// "_CrOS_Photo_PositionA_2" -> "TopicB"
// "_CrOS_Photo_PositionB_1" -> "TopicC"
// "_CrOS_Photo_PositionB_2" -> "TopicD"
//
// At the start of each new animation cycle, the provider first "rotates" the
// topics at each position. The topic previously assigned to asset with index
// <i + 1> is now assigned to asset with index <i> for all <i> at a given
// position. After rotation, the asset with the highest index at each position
// is left without an assigned topic:
// "_CrOS_Photo_PositionA_1" -> "TopicB"
// "_CrOS_Photo_PositionA_2" -> ???
// "_CrOS_Photo_PositionB_1" -> "TopicD"
// "_CrOS_Photo_PositionB_2" -> ???
//
// The provider then pulls the latest 2 topics from the model (since there are
// 2 assets left now without an assigned topic), and assigns a new topic to
// those assets.
// "_CrOS_Photo_PositionA_1" -> "TopicB"
// "_CrOS_Photo_PositionA_2" -> "TopicE" (new)
// "_CrOS_Photo_PositionB_1" -> "TopicD"
// "_CrOS_Photo_PositionB_2" -> "TopicF" (new)
//
// The process above repeats for each new animation cycle. Note the process
// generalizes to the simplest case where there is only 1 assigned topic per
// position. Rotation will just leave all assets in the animation without an
// assigned topic.
#include "ash/ambient/model/ambient_animation_photo_provider.h"
#include <algorithm>
#include <functional>
#include <iterator>
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include "ash/ambient/metrics/ambient_metrics.h"
#include "ash/ambient/resources/ambient_animation_resource_constants.h"
#include "ash/ambient/resources/ambient_animation_static_resources.h"
#include "ash/ambient/util/ambient_util.h"
#include "ash/utility/cropping_util.h"
#include "ash/utility/lottie_util.h"
#include "base/check.h"
#include "base/functional/bind.h"
#include "base/functional/callback.h"
#include "base/logging.h"
#include "base/memory/scoped_refptr.h"
#include "base/notreached.h"
#include "base/numerics/ranges.h"
#include "base/rand_util.h"
#include "cc/paint/paint_flags.h"
#include "cc/paint/skottie_frame_data.h"
#include "ui/gfx/geometry/size.h"
#include "ui/gfx/image/image_skia.h"
#include "ui/gfx/image/image_skia_rep.h"
namespace ash {
namespace {
// TODO(esum): Experiment with different filter qualities for different asset
// types. Thus far, "high" quality has a large impact on performance;
// the frame rate is cut in half due to the increased computational
// complexity. "Medium" quality is the best compromise so far with little to
// no visible difference from "high" quality while maintaining close to 60
// fps.
constexpr cc::PaintFlags::FilterQuality kFilterQuality =
cc::PaintFlags::FilterQuality::kMedium;
cc::SkottieFrameData BuildSkottieFrameData(const gfx::ImageSkia& image,
float scale_factor) {
DCHECK(!image.isNull());
const gfx::ImageSkiaRep& image_rep = image.GetRepresentation(scale_factor);
DCHECK(!image_rep.is_null());
DCHECK(image_rep.has_paint_image());
return {
/*image=*/image_rep.paint_image(),
/*quality=*/kFilterQuality,
};
}
bool IsPortrait(const gfx::Size& size) {
DCHECK(!size.IsEmpty());
return size.height() > size.width();
}
// Provides images for dynamic assets based on the following UX requirements:
// * Make a best effort to assign portrait images to portrait assets and same
// for landscape.
// * If there are less topics available than the number of dynamic assets in
// the animation, the available photos should be evenly distributed and
// duplicated among the assets. For example, if there are 2 topics available
// and 6 dynamic assets, each topic should appear 3 times.
// * The photos should be shuffled among the assets between animation cycles.
class DynamicImageProvider {
public:
using TopicReferenceVector =
std::vector<std::reference_wrapper<const PhotoWithDetails>>;
explicit DynamicImageProvider(TopicReferenceVector all_available_topics) {
DCHECK(!all_available_topics.empty())
<< "Animation should not have started rendering without any decoded "
"photos in the model.";
base::RandomShuffle(all_available_topics.begin(),
all_available_topics.end());
for (auto& topic_ref : all_available_topics) {
// Note the AmbientPhotoConfig for animations states that topics from IMAX
// containing primary and related photos should be split into 2. So the
// related photo should always be null (hence no point in reading it).
DCHECK(!topic_ref.get().photo.isNull());
if (IsPortrait(topic_ref.get().photo.size())) {
portrait_set_.topics.push_back(std::move(topic_ref));
} else {
landscape_set_.topics.push_back(std::move(topic_ref));
}
}
}
const PhotoWithDetails& GetTopicForAssetSize(
const std::optional<gfx::Size>& asset_size) {
const PhotoWithDetails* topic = nullptr;
// If the |asset_size| is unavailable, this is unexpected but not fatal. The
// choice to default to portrait is arbitrary.
if (!asset_size || IsPortrait(*asset_size)) {
topic = GetNextTopic(/*primary_topic_set=*/portrait_set_,
/*secondary_topic_set=*/landscape_set_);
} else {
topic = GetNextTopic(/*primary_topic_set=*/landscape_set_,
/*secondary_topic_set=*/portrait_set_);
}
DCHECK(topic);
TryResetCurrentTopicIndices();
return *topic;
}
private:
struct TopicSet {
// Not mutated after DynamicImageProvider's constructor.
TopicReferenceVector topics;
// Incremented each time a topic is picked from the set and loops back to
// 0 when all topics from all TopicSets have been exhausted.
size_t current_topic_idx = 0;
};
static const PhotoWithDetails* GetNextTopicFromTopicSet(TopicSet& topic_set) {
if (topic_set.current_topic_idx >= topic_set.topics.size())
return nullptr;
const PhotoWithDetails* topic =
&topic_set.topics[topic_set.current_topic_idx].get();
++topic_set.current_topic_idx;
return topic;
}
static const PhotoWithDetails* GetNextTopic(TopicSet& primary_topic_set,
TopicSet& secondary_topic_set) {
const PhotoWithDetails* topic = GetNextTopicFromTopicSet(primary_topic_set);
return topic ? topic : GetNextTopicFromTopicSet(secondary_topic_set);
}
void TryResetCurrentTopicIndices() {
// Once all available topics have been exhausted, reset the
// |current_topic_idx| for each TopicSet to start "fresh" again.
if (landscape_set_.current_topic_idx >= landscape_set_.topics.size() &&
portrait_set_.current_topic_idx >= portrait_set_.topics.size()) {
landscape_set_.current_topic_idx = 0;
portrait_set_.current_topic_idx = 0;
}
}
TopicSet landscape_set_;
TopicSet portrait_set_;
};
} // namespace
class AmbientAnimationPhotoProvider::StaticImageAssetImpl
: public cc::SkottieFrameDataProvider::ImageAsset {
public:
StaticImageAssetImpl(std::string_view asset_id,
const AmbientAnimationStaticResources& static_resources)
: image_(static_resources.GetStaticImageAsset(asset_id)) {
DCHECK(!IsCustomizableLottieId(asset_id));
DCHECK(!image_.isNull())
<< "Static image asset " << asset_id << " is unknown.";
DVLOG(1) << "Loaded static asset " << asset_id;
}
cc::SkottieFrameData GetFrameData(float t, float scale_factor) override {
if (!enabled_)
return cc::SkottieFrameData();
if (!current_frame_data_.image ||
current_frame_data_scale_factor_ != scale_factor) {
current_frame_data_ = BuildSkottieFrameData(image_, scale_factor);
current_frame_data_scale_factor_ = scale_factor;
}
return current_frame_data_;
}
bool enabled() const { return enabled_; }
void set_enabled(bool enabled) { enabled_ = enabled; }
private:
// Private destructor since cc::SkottieFrameDataProvider::ImageAsset is a
// ref-counted API.
~StaticImageAssetImpl() override = default;
const gfx::ImageSkia image_;
cc::SkottieFrameData current_frame_data_;
float current_frame_data_scale_factor_ = 0;
bool enabled_ = true;
};
class AmbientAnimationPhotoProvider::DynamicImageAssetImpl
: public cc::SkottieFrameDataProvider::ImageAsset {
public:
DynamicImageAssetImpl(
std::string_view asset_id,
std::optional<gfx::Size> size,
const base::WeakPtr<AmbientAnimationPhotoProvider>& provider)
: asset_id_(asset_id), size_(std::move(size)), provider_(provider) {
DCHECK(provider_);
if (!ambient::util::ParseDynamicLottieAssetId(asset_id, parsed_asset_id_)) {
LOG(DFATAL) << "Animation file is invalid. Failed to parse dynamic "
"image asset id "
<< asset_id;
}
if (!size_)
DLOG(ERROR) << "Dimensions unavailable for dynamic asset " << asset_id_;
}
cc::SkottieFrameData GetFrameData(float t, float scale_factor) override {
DVLOG(4) << "GetFrameData for asset " << asset_id_ << " time " << t;
bool is_first_rendered_frame =
last_observed_animation_timestamp_ == kAnimationTimestampInvalid;
// The animation frame timestamp units are dictated by Skottie and are
// irrelevant here. The timestamp for each individual asset is monotonically
// increasing until the animation loops back to the beginning, indicating
// the start of a new cycle.
bool is_starting_new_cycle = t < last_observed_animation_timestamp_;
last_observed_animation_timestamp_ = t;
if (is_first_rendered_frame || is_starting_new_cycle) {
DVLOG(4) << "Returning new image for dynamic asset " << asset_id_;
if (provider_) {
current_topic_ = provider_->GenerateNextTopicForDynamicAsset(*this);
// Force |current_frame_data_| to be reset below.
current_frame_data_scale_factor_ = kImageScaleFactorInvalid;
} else {
// If this corner case does somehow happen, it will only be for a brief
// period when the animation is being torn down.
DVLOG(1) << "AmbientAnimationPhotoProvider has been destroyed. Cannot "
"refresh images.";
}
} else {
DVLOG(4) << "No update required to dynamic asset at this time";
}
SetCurrentFrameDataForScale(scale_factor);
return current_frame_data_;
}
PhotoWithDetails ExtractAssignedTopic() {
current_frame_data_ = cc::SkottieFrameData();
current_frame_data_scale_factor_ = kImageScaleFactorInvalid;
return std::move(current_topic_);
}
bool HasAssignedTopic() const { return !current_topic_.photo.isNull(); }
const std::optional<gfx::Size>& size() const { return size_; }
const std::string& asset_id() const { return asset_id_; }
const ambient::util::ParsedDynamicAssetId& parsed_asset_id() const {
return parsed_asset_id_;
}
const std::string& position_id() const {
return parsed_asset_id_.position_id;
}
int idx() const { return parsed_asset_id_.idx; }
private:
static constexpr float kAnimationTimestampInvalid = -1.f;
static constexpr float kImageScaleFactorInvalid = 0.f;
// Private destructor since cc::SkottieFrameDataProvider::ImageAsset is a
// ref-counted API.
~DynamicImageAssetImpl() override = default;
void SetCurrentFrameDataForScale(float scale_factor) {
static constexpr float kScaleFactorEpsilon = 0.01f;
DCHECK(!current_topic_.photo.isNull());
if (current_frame_data_scale_factor_ != kImageScaleFactorInvalid &&
base::IsApproximatelyEqual(current_frame_data_scale_factor_,
scale_factor, kScaleFactorEpsilon)) {
DVLOG(4) << "Current frame data already matches target scale.";
return;
}
// First load the closest image representation from the source at the target
// |scale_factor|, then crop the image representation to the asset's aspect
// ratio.
const gfx::ImageSkiaRep& image_rep =
current_topic_.photo.GetRepresentation(scale_factor);
DCHECK(!image_rep.is_null());
cc::PaintImage paint_image;
if (size_) {
// Crop the image such that it exactly matches this asset's aspect ratio.
// Skottie will handle rescaling the image to the exact desired
// dimensions farther down the pipeline.
SkBitmap cropped_bitmap = CenterCropImage(image_rep.GetBitmap(), *size_);
// Prevents a deep copy in PaintImage::CreateFromBitmap().
cropped_bitmap.setImmutable();
paint_image = cc::PaintImage::CreateFromBitmap(std::move(cropped_bitmap));
} else {
DLOG(ERROR) << "Dynamic asset " << asset_id_
<< " missing dimensions in lottie file";
DCHECK(image_rep.has_paint_image());
paint_image = image_rep.paint_image();
}
current_frame_data_.image = std::move(paint_image);
current_frame_data_.quality = kFilterQuality;
current_frame_data_scale_factor_ = scale_factor;
}
const std::string asset_id_;
ambient::util::ParsedDynamicAssetId parsed_asset_id_;
const std::optional<gfx::Size> size_;
const base::WeakPtr<AmbientAnimationPhotoProvider> provider_;
// Last animation frame timestamp that was observed.
float last_observed_animation_timestamp_ = kAnimationTimestampInvalid;
cc::SkottieFrameData current_frame_data_;
float current_frame_data_scale_factor_ = kImageScaleFactorInvalid;
// The original topic off of which |current_frame_data_| was built. May have
// multiple scale representations in its image in the event that a different
// scale factor is required while rendering.
PhotoWithDetails current_topic_;
};
bool AmbientAnimationPhotoProvider::OrderDynamicAssetsByIdx::operator()(
const scoped_refptr<DynamicImageAssetImpl>& asset_l,
const scoped_refptr<DynamicImageAssetImpl>& asset_r) const {
DCHECK(asset_l);
DCHECK(asset_r);
return asset_l->idx() < asset_r->idx();
}
AmbientAnimationPhotoProvider::AmbientAnimationPhotoProvider(
const AmbientAnimationStaticResources* static_resources,
const AmbientBackendModel* backend_model)
: static_resources_(static_resources),
backend_model_(backend_model),
weak_factory_(this) {
DCHECK(static_resources_);
DCHECK(backend_model_);
}
AmbientAnimationPhotoProvider::~AmbientAnimationPhotoProvider() = default;
scoped_refptr<cc::SkottieFrameDataProvider::ImageAsset>
AmbientAnimationPhotoProvider::LoadImageAsset(
std::string_view asset_id,
const base::FilePath& resource_path,
const std::optional<gfx::Size>& size) {
// Note in practice, all of the image assets are loaded one time by Skottie
// when the animation is initially loaded. So the set of assets does not
// change once the animation starts rendering.
if (IsCustomizableLottieId(asset_id)) {
auto dynamic_asset = base::MakeRefCounted<DynamicImageAssetImpl>(
asset_id, size, weak_factory_.GetWeakPtr());
dynamic_assets_per_position_[dynamic_asset->position_id()].insert(
dynamic_asset);
++total_num_dynamic_assets_;
return dynamic_asset;
} else {
// For static assets, the |size| isn't needed. It should match the size of
// the image loaded from animation's |static_resources_| since that is the
// very image created by UX when the animation was built.
auto static_asset = base::MakeRefCounted<StaticImageAssetImpl>(
asset_id, *static_resources_);
const auto hash_id = cc::HashSkottieResourceId(asset_id);
static_assets_[hash_id] = static_asset;
if (hash_id ==
cc::HashSkottieResourceId(ambient::resources::kTreeShadowAssetId)) {
static_asset->set_enabled(enable_tree_shadow_);
}
return static_asset;
}
}
void AmbientAnimationPhotoProvider::AddObserver(Observer* obs) {
observers_.AddObserver(obs);
}
void AmbientAnimationPhotoProvider::RemoveObserver(Observer* obs) {
observers_.RemoveObserver(obs);
}
bool AmbientAnimationPhotoProvider::ToggleStaticImageAsset(
cc::SkottieResourceIdHash asset_id,
bool enabled) {
auto iter = static_assets_.find(asset_id);
if (iter == static_assets_.end()) {
// When the view is first created, all assets might not be loaded yet. Store
// the `enabled` state to apply on the tree shadow asset when it is loaded.
enable_tree_shadow_ = enabled;
} else {
iter->second->set_enabled(enabled);
}
return true;
}
// Invoked whenever an asset detects a new animation cycle has started. In
// practice, there may be multiple dynamic assets in an animation. So the
// first asset that detects a new animation cycle (which is arbitrary), will
// cause the provider internally to find a new topic for *all* dynamic assets in
// the animation. The provider then returns the first asset's assigned topic and
// saves the other N - 1 assets' topics, marking them as pending. When the other
// N - 1 assets call GenerateNextTopicForDynamicAsset() shortly after, the
// provider simply retrieves the corresponding pending topic until the set of
// pending topics is empty. This process then repeats at the start of the next
// animation cycle.
PhotoWithDetails
AmbientAnimationPhotoProvider::GenerateNextTopicForDynamicAsset(
const DynamicImageAssetImpl& target_asset) {
DVLOG(4) << __func__;
PhotoWithDetails topic_for_target_asset =
ExtractPendingTopicForDynamicAsset(target_asset);
if (!topic_for_target_asset.photo.isNull()) {
return topic_for_target_asset;
}
DCHECK(pending_dynamic_asset_topics_.empty())
<< "All pending topics should have been returned before the first frame "
"of each animation cycle.";
RotateDynamicAssetTopics();
DynamicImageProvider image_provider(GetTopicsToChooseFrom());
for (const auto& [_, dynamic_asset_set] : dynamic_assets_per_position_) {
for (const auto& dynamic_asset : dynamic_asset_set) {
bool asset_already_has_assigned_topic =
pending_dynamic_asset_topics_.contains(dynamic_asset.get());
if (asset_already_has_assigned_topic)
continue;
pending_dynamic_asset_topics_.emplace(
dynamic_asset.get(),
image_provider.GetTopicForAssetSize(dynamic_asset->size()));
}
}
NotifyObserverOfNewTopics();
topic_for_target_asset = ExtractPendingTopicForDynamicAsset(target_asset);
DCHECK(!topic_for_target_asset.photo.isNull())
<< "GenerateNextTopicForDynamicAsset() for unknown asset "
<< target_asset.asset_id();
return topic_for_target_asset;
}
PhotoWithDetails
AmbientAnimationPhotoProvider::ExtractPendingTopicForDynamicAsset(
const DynamicImageAssetImpl& asset) {
auto pending_topic_iter = pending_dynamic_asset_topics_.find(&asset);
if (pending_topic_iter == pending_dynamic_asset_topics_.end()) {
return PhotoWithDetails();
} else {
PhotoWithDetails pending_topic = std::move(pending_topic_iter->second);
pending_dynamic_asset_topics_.erase(pending_topic_iter);
return pending_topic;
}
}
// For each position, the topic assigned to asset with index <i + 1> gets
// assigned to the asset with index <i>. Ultimately, the asset with the highest
// index at each position is left without an assigned topic. See comments at
// the top of the file for an example.
void AmbientAnimationPhotoProvider::RotateDynamicAssetTopics() {
for (const auto& [_, dynamic_asset_set] : dynamic_assets_per_position_) {
DCHECK(!dynamic_asset_set.empty());
auto current_asset = dynamic_asset_set.begin();
auto next_asset = std::next(current_asset);
for (; next_asset != dynamic_asset_set.end();
++current_asset, ++next_asset) {
// HasAssignedTopic() should only be false on the very first frame.
if ((*next_asset)->HasAssignedTopic()) {
pending_dynamic_asset_topics_[current_asset->get()] =
(*next_asset)->ExtractAssignedTopic();
}
}
}
}
std::vector<std::reference_wrapper<const PhotoWithDetails>>
AmbientAnimationPhotoProvider::GetTopicsToChooseFrom() const {
const base::circular_deque<PhotoWithDetails>& all_available_topics =
backend_model_->all_decoded_topics();
size_t num_assets_without_assigned_topic =
total_num_dynamic_assets_ - pending_dynamic_asset_topics_.size();
// Clamp |num_assets_without_assigned_topic| in case the controller is having
// a hard time preparing new topics (ex: network congestion) and there are
// minimal topics in the model (1 is the bare minimum).
size_t num_available_topics = all_available_topics.size();
size_t num_topics_to_choose_from =
std::min(num_assets_without_assigned_topic, num_available_topics);
// |all_available_topics| is ordered from least recent to most recent, so
// choose from the topics beginning at the end of the queue.
std::vector<std::reference_wrapper<const PhotoWithDetails>>
topics_to_choose_from;
auto range_begin = all_available_topics.rbegin();
auto range_end = range_begin + num_topics_to_choose_from;
for (auto topic_iter = range_begin; topic_iter != range_end; ++topic_iter) {
topics_to_choose_from.push_back(std::cref(*topic_iter));
}
return topics_to_choose_from;
}
void AmbientAnimationPhotoProvider::NotifyObserverOfNewTopics() {
base::flat_map<ambient::util::ParsedDynamicAssetId,
std::reference_wrapper<const PhotoWithDetails>>
new_topics;
for (const auto& [asset, topic] : pending_dynamic_asset_topics_) {
new_topics.emplace(asset->parsed_asset_id(), std::cref(topic));
}
for (Observer& obs : observers_) {
obs.OnDynamicImageAssetsRefreshed(new_topics);
}
}
} // namespace ash