chromium/ash/ambient/ui/ambient_animation_frame_rate_controller.cc

// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "ash/ambient/ui/ambient_animation_frame_rate_controller.h"

#include "ash/frame_throttler/frame_throttling_controller.h"
#include "base/check.h"
#include "base/logging.h"
#include "base/memory/raw_ptr.h"
#include "base/notreached.h"
#include "base/time/time.h"
#include "cc/paint/skottie_wrapper.h"
#include "components/viz/common/surfaces/frame_sink_id.h"
#include "ui/aura/window.h"

namespace ash {
namespace {

AmbientAnimationFrameRateSchedule BuildSchedule(lottie::Animation* animation) {
  DCHECK(animation);
  AmbientAnimationFrameRateSchedule schedule =
      BuildAmbientAnimationFrameRateSchedule(
          animation->skottie()->GetAllMarkers());
  if (schedule.empty()) {
    // This means the animation file needs to be fixed. This should never happen
    // in the field in theory, but just in case, resort to the default frame
    // rate schedule (no throttling).
    LOG(DFATAL) << "Ambient animation has invalid frame rate markers.";
    schedule = BuildDefaultFrameRateSchedule();
  }
  return schedule;
}

}  // namespace

AmbientAnimationFrameRateController::AmbientAnimationFrameRateController(
    FrameThrottlingController* frame_throttling_controller)
    : frame_throttling_controller_(frame_throttling_controller),
      current_section_(schedule_.end()) {
  DCHECK(frame_throttling_controller_);
}

AmbientAnimationFrameRateController::~AmbientAnimationFrameRateController() {
  ThrottleFrameRate(kDefaultFrameInterval);
}

void AmbientAnimationFrameRateController::AnimationFramePainted(
    const lottie::Animation* animation,
    float) {
  if (animation != tracking_animation_.get())
    return;

  auto new_current_section = FindCurrentSection();
  if (new_current_section == current_section_)
    return;

  DVLOG(1) << "Found new frame rate section: " << *new_current_section;
  current_section_ = new_current_section;
  ThrottleFrameRateForCurrentSection();
}

// Note either AnimationIsDeleting() or OnWindowDestroying() could come first.
// Whichever one does should cause both the window and animation to be removed
// from book-keeping entirely.
void AmbientAnimationFrameRateController::AnimationIsDeleting(
    const lottie::Animation* deleting_animation) {
  aura::Window* window_to_remove = nullptr;
  for (const auto& [window, animation] : windows_to_throttle_) {
    if (animation == deleting_animation) {
      window_to_remove = window;
      break;
    }
  }
  DCHECK(window_to_remove);
  RemoveWindowToThrottle(window_to_remove);
}

void AmbientAnimationFrameRateController::OnWindowDestroying(
    aura::Window* window) {
  RemoveWindowToThrottle(window);
}

void AmbientAnimationFrameRateController::AddWindowToThrottle(
    aura::Window* window,
    lottie::Animation* animation) {
  DCHECK(window);
  DCHECK(window->GetFrameSinkId().is_valid())
      << "Window missing frame sink id: " << window->GetId();
  DCHECK(animation);
  if (windows_to_throttle_.contains(window))
    return;

  if (tracking_animation_) {
    DCHECK_EQ(tracking_animation_->skottie()->id(), animation->skottie()->id())
        << "All lottie animations must have the same json content";
  }
  windows_to_throttle_[window] = animation;
  TrySetNewTrackingAnimation();
  // Always observe even if the incoming |animation| is not the
  // |tracking_animation_| so that we get notified when AnimationIsDeleting().
  animation_observations_.AddObservation(animation);
  window_observations_.AddObservation(window);
  // Update throttling with the expanded list of |windows_to_throttle_|.
  ThrottleFrameRateForCurrentSection();
}

AmbientAnimationFrameRateScheduleIterator
AmbientAnimationFrameRateController::FindCurrentSection() const {
  DCHECK(tracking_animation_);
  std::optional<float> current_progress =
      tracking_animation_->GetCurrentProgress();
  if (!current_progress) {
    DVLOG(1) << "Animation is not playing currently. Cannot map timestamp to "
                "scheduled frame rate.";
    return schedule_.end();
  }

  // Always start searching from the last section the animation was on. Since
  // animations progress linearly in small increments, most of the time, the
  // |current_section_| will not change.
  AmbientAnimationFrameRateScheduleIterator new_current_section =
      current_section_ == schedule_.end() ? schedule_.begin()
                                          : current_section_;
  AmbientAnimationFrameRateScheduleIterator orig_current_section =
      new_current_section;
  while (!new_current_section->Contains(*current_progress)) {
    ++new_current_section;
    // Note the AmbientAnimationFrameRateSchedule by design is contiguous. Every
    // possible timestamp falls within a section of the schedule, so it's
    // impossible to infinite loop here.
    DCHECK(new_current_section != orig_current_section)
        << "Infinite loop detected. AmbientAnimationFrameRateSchedule has gap "
           "and is malformed.";
    // The schedule is cyclic. Loop back to the beginning.
    if (new_current_section == schedule_.end())
      new_current_section = schedule_.begin();
  }
  return new_current_section;
}

void AmbientAnimationFrameRateController::ThrottleFrameRateForCurrentSection() {
  // TODO(esum): There is a corner case not accounted for yet. Say the frame
  // interval is large (1 second). And say we throttle to 1 fps at time 10 sec,
  // and we need to restore the default 60 fps at 19.1 sec. We will get:
  // AnimationFramePainted(10 sec)
  // AnimationFramePainted(11 sec)
  // ...
  // AnimationFramePainted(19 sec) - Still not past the 19.1 second mark
  // AnimationFramePainted(20 sec) - Switch back to 60 fps here.
  //
  // This is bad because we switch back .9 seconds too late, which is a lot.
  // To fix this, we could start a timer that fires when the current section is
  // over and we need to switch to the new frame rate. It currently is not a
  // problem because in practice, the frame rates never get small enough to
  // notice this issue. But it will be a problem with the slideshow lottie
  // animation.
  if (current_section_ != schedule_.end())
    ThrottleFrameRate(current_section_->frame_interval);
}

void AmbientAnimationFrameRateController::ThrottleFrameRate(
    base::TimeDelta frame_interval) {
  std::vector<raw_ptr<aura::Window, VectorExperimental>> windows_as_vector;
  for (const auto& [window, animation] : windows_to_throttle_) {
    windows_as_vector.push_back(window);
  }

  if (frame_interval == kDefaultFrameInterval) {
    VLOG(1) << "Resetting frame rate to default";
    frame_throttling_controller_->EndThrottling();
  } else {
    DVLOG(1) << "Throttling frame rate to " << frame_interval.ToHz() << "hz";
    frame_throttling_controller_->StartThrottling(windows_as_vector,
                                                  frame_interval);
  }
}

void AmbientAnimationFrameRateController::RemoveWindowToThrottle(
    aura::Window* window) {
  window_observations_.RemoveObservation(window);
  auto iter = windows_to_throttle_.find(window);
  DCHECK(iter != windows_to_throttle_.end());
  lottie::Animation* unregistered_animation = iter->second;
  animation_observations_.RemoveObservation(unregistered_animation);
  windows_to_throttle_.erase(iter);
  if (unregistered_animation != tracking_animation_)
    return;

  tracking_animation_ = nullptr;
  TrySetNewTrackingAnimation();
  if (tracking_animation_) {
    DVLOG(1)
        << "Observing new lottie Animation. Resetting frame rate throttling.";
    ThrottleFrameRateForCurrentSection();
  } else {
    DVLOG(1) << "No more lottie animations are active. Restoring default frame "
                "rate and going idle...";
    ThrottleFrameRate(kDefaultFrameInterval);
  }
}

void AmbientAnimationFrameRateController::TrySetNewTrackingAnimation() {
  if (tracking_animation_) {
    DVLOG(4) << "Tracking animation already set.";
    return;
  }

  if (windows_to_throttle_.empty()) {
    DVLOG(4) << "No lottie animations to track. Going idle.";
    return;
  }

  tracking_animation_ = windows_to_throttle_.begin()->second;
  schedule_ = BuildSchedule(tracking_animation_.get());
  // Set |current_section_| to be |schedule_.end()| temporarily so that
  // FindCurrentSection() knows to start searching the new schedule from
  // scratch.
  current_section_ = schedule_.end();
  current_section_ = FindCurrentSection();
}

}  // namespace ash