LibWeb: Transfer animation ownership to AnimatedBitmapDecodedImageData

Previously animation ownership was a messy split between
`AnimatedBitmapDecodedImageData` and the consumers (i.e.
`ImageStyleValueResource`, `HTMLImageElement`, and `SVGImageElement`)
with `AnimatedBitmapDecodedImageData` owning the frames and a current
frame index, and the consumers owning the rest of the state (e.g. loop
count, timers to drive the animation forward, their own current index).

This had a couple of main issues:
 - While `AnimatedDecodedImageData` partially synchronized animations by
   dropping unexpected advancement notifications, this didn't apply to
   other animation state which meant, for instance, that a later started
   consumer could drive the animation of an earlier one past the max
   loop count (albeit without invalidating the earlier consumer).
 - Multiple consumers didn't share frame timings, meaning animations
   could be up to a full frame out of sync visually.
 - Animations were paused depending on whether there were any consumers,
   this is different to the behavior in other browsers (where they
   continue regardless of whether there are any consumers).
 - It was an overgeneralization of how animations need to work - only
   `AnimatedBitmapDecodedImageData` works with an indexed frame model,
   with animated SVGs (although not yet implemented) relying on their
   internal event loop to be driven forward.

Given the above the new approach implemented in this commit is:
 - The API for `DecodedImageData` is animation system agnostic, only
   exposing `default_frame`, `current_frame`, and `restart_animation`
   methods not reliant on providing a specific frame index.
 - `AnimatedBitmapDecodedImageData` owns its own timer, loop count,
   etc. The animation starts when the first consumer registers and ends
   when the document is hidden or becomes inactive (or completes in the
   case of finite animations).
 - Consumers are invalidated by `AnimatedBitmapDecodedImageData` when
   required.

Tests have been added for:
 - Animations being paused when the document becomes inactive and
   restarted when it becomes active again.
 - Frame timings being synchronized across consumers.
 - Restarts triggered by `HTMLImageElement` applying to all consumers.
 - Processing ending once a non-infinite animation plays to completion.

The tests to ensure animations are cancelled when consumers are removed
(e.g. `animated-background-image-timer-stops-when-hidden.html`) have
been updated to assert the inverse since animation state is now per
resource not per consumer.
This commit is contained in:
Callum Law 2026-06-16 10:56:00 +12:00 committed by Alexander Kalenik
parent 27381e9b00
commit 8ebdaeab69
62 changed files with 568 additions and 433 deletions

View file

@ -688,6 +688,7 @@ set(SOURCES
HTML/SessionHistoryTraversalQueue.cpp
HTML/SharedResourceRequest.cpp
HTML/AnimatedBitmapDecodedImageData.cpp
HTML/AnimatedDecodedImageData.cpp
HTML/SharedWorker.cpp
HTML/SharedWorkerGlobalScope.cpp
HTML/SourceSet.cpp

View file

@ -8,7 +8,6 @@
*/
#include <AK/AnyOf.h>
#include <LibGC/Function.h>
#include <LibGC/Weak.h>
#include <LibGfx/DecodedImageFrame.h>
#include <LibWeb/CSS/CSSStyleSheet.h>
@ -22,7 +21,6 @@
#include <LibWeb/HTML/SharedResourceRequest.h>
#include <LibWeb/Painting/DisplayListRecorder.h>
#include <LibWeb/Painting/DisplayListRecordingContext.h>
#include <LibWeb/Platform/Timer.h>
namespace Web::CSS {
@ -34,7 +32,7 @@ ImageStyleValueResource::ImageStyleValueResource(GC::Ref<HTML::SharedResourceReq
// FIXME: Can we directly access the resource (i.e. this) here instead of looking it up in the document?
if (auto document = weak_document.ptr()) {
if (auto* resource = document->css_image_resource(url))
resource->on_decoded_image_data_loaded(*document);
resource->on_decoded_image_data_loaded();
}
},
nullptr);
@ -42,7 +40,6 @@ ImageStyleValueResource::ImageStyleValueResource(GC::Ref<HTML::SharedResourceReq
ImageStyleValueResource::~ImageStyleValueResource()
{
stop_animation_timer();
VERIFY(m_image_style_values.is_empty());
unregister_with_decoded_image_data_if_needed();
}
@ -50,23 +47,19 @@ ImageStyleValueResource::~ImageStyleValueResource()
void ImageStyleValueResource::visit_edges(JS::Cell::Visitor& visitor)
{
visitor.visit(m_resource_request);
visitor.visit(m_timer);
}
void ImageStyleValueResource::register_image_style_value(DOM::Document& document, ImageStyleValue const& image_style_value)
void ImageStyleValueResource::register_image_style_value(ImageStyleValue const& image_style_value)
{
m_image_style_values.set(&image_style_value);
start_animation_timer_if_needed(document);
register_with_decoded_image_data_if_needed();
}
void ImageStyleValueResource::unregister_image_style_value(ImageStyleValue const& image_style_value)
{
m_image_style_values.remove(&image_style_value);
if (m_image_style_values.is_empty()) {
stop_animation_timer();
if (m_image_style_values.is_empty())
unregister_with_decoded_image_data_if_needed();
}
}
GC::Ptr<HTML::DecodedImageData> ImageStyleValueResource::decoded_image_data() const
@ -74,15 +67,9 @@ GC::Ptr<HTML::DecodedImageData> ImageStyleValueResource::decoded_image_data() co
return m_resource_request->image_data();
}
bool ImageStyleValueResource::has_active_animation_timer() const
{
return m_timer && m_timer->is_active();
}
void ImageStyleValueResource::on_decoded_image_data_loaded(DOM::Document& document)
void ImageStyleValueResource::on_decoded_image_data_loaded()
{
notify_image_style_values_did_update();
start_animation_timer_if_needed(document);
if (!m_image_style_values.is_empty())
register_with_decoded_image_data_if_needed();
}
@ -93,79 +80,6 @@ void ImageStyleValueResource::notify_image_style_values_did_update()
image_style_value->notify_clients_did_update();
}
void ImageStyleValueResource::start_animation_timer_if_needed(DOM::Document& document)
{
if (m_image_style_values.is_empty() || !is_animatable())
return;
if (m_timer && m_timer->is_active())
return;
if (!m_timer) {
auto timer = Platform::Timer::create(document.heap());
m_timer = timer;
timer->on_timeout = GC::create_function(document.heap(), [weak_document = GC::Weak(document), url = m_resource_request->url()] {
if (auto document = weak_document.ptr())
document->animate_css_image_resource(url);
});
}
m_timer->set_interval(current_frame_duration());
m_timer->start();
}
void ImageStyleValueResource::stop_animation_timer()
{
if (m_timer && m_timer->is_active())
m_timer->stop();
}
bool ImageStyleValueResource::is_animatable() const
{
auto image_data = this->decoded_image_data();
if (!image_data || !image_data->is_animated() || image_data->frame_count() <= 1)
return false;
return !animation_has_completed();
}
bool ImageStyleValueResource::animation_has_completed() const
{
auto image_data = this->decoded_image_data();
return image_data && image_data->loop_count() > 0 && m_loops_completed == image_data->loop_count();
}
int ImageStyleValueResource::current_frame_duration() const
{
auto image_data = this->decoded_image_data();
if (!image_data)
return 0;
return image_data->frame_duration(m_current_frame_index);
}
void ImageStyleValueResource::animate(DOM::Document&)
{
auto image_data = m_resource_request->image_data();
if (!image_data)
return;
m_current_frame_index = (m_current_frame_index + 1) % image_data->frame_count();
m_current_frame_index = image_data->notify_frame_advanced(m_current_frame_index);
auto current_frame_duration = image_data->frame_duration(m_current_frame_index);
if (m_timer && current_frame_duration != m_timer->interval())
m_timer->restart(current_frame_duration);
if (m_current_frame_index == image_data->frame_count() - 1) {
++m_loops_completed;
if (animation_has_completed())
stop_animation_timer();
}
notify_image_style_values_did_update();
}
ValueComparingNonnullRefPtr<ImageStyleValue const> ImageStyleValue::create(URL const& url)
{
return adopt_ref(*new (nothrow) ImageStyleValue(url));
@ -190,11 +104,6 @@ ImageStyleValue::ImageStyleValue(URL const& url, Optional<::URL::URL> style_reso
ImageStyleValue::~ImageStyleValue() = default;
u64 ImageStyleValue::active_animation_timer_count(DOM::Document const& document)
{
return document.active_css_image_animation_timer_count();
}
GC::Ptr<HTML::SharedResourceRequest> ImageStyleValue::fetch_image(DOM::Document& document) const
{
RuleOrDeclaration rule_or_declaration {
@ -256,30 +165,17 @@ void ImageStyleValue::paint(DisplayListRecordingContext& context, DOM::Document
if (!image_data)
return;
auto current_frame_index = this->current_frame_index(document);
auto dest_int_rect = dest_rect.to_type<int>();
image_data->paint(context, current_frame_index, dest_int_rect, image_rendering);
image_data->paint(context, dest_int_rect, image_rendering);
}
Optional<Gfx::DecodedImageFrame> ImageStyleValue::current_frame(DOM::Document const& document, DevicePixelRect const& dest_rect) const
{
if (auto image_data = this->image_data(document))
return image_data->frame(current_frame_index(document), dest_rect.size().to_type<int>());
return image_data->current_frame(dest_rect.size().to_type<int>());
return {};
}
size_t ImageStyleValue::current_frame_index(DOM::Document const& document) const
{
auto resolved_url = this->resolved_url(document);
if (!resolved_url.has_value())
return 0;
if (auto const* resource = document.css_image_resource(*resolved_url))
return resource->current_frame_index();
return 0;
}
GC::Ptr<HTML::DecodedImageData> ImageStyleValue::image_data(DOM::Document const& document) const
{
auto resolved_url = this->resolved_url(document);
@ -397,7 +293,7 @@ void ImageStyleValue::register_client(Client& client) const
resource = document->create_css_image_resource(*resource_request);
}
resource->register_image_style_value(*document, *this);
resource->register_image_style_value(*this);
}
void ImageStyleValue::unregister_client(Client& client) const

View file

@ -32,32 +32,20 @@ public:
void visit_edges(JS::Cell::Visitor&);
void register_image_style_value(DOM::Document&, ImageStyleValue const&);
void register_image_style_value(ImageStyleValue const&);
void unregister_image_style_value(ImageStyleValue const&);
bool can_be_removed() const { return m_image_style_values.is_empty(); }
[[nodiscard]] virtual GC::Ptr<HTML::DecodedImageData> decoded_image_data() const override;
[[nodiscard]] size_t current_frame_index() const { return m_current_frame_index; }
[[nodiscard]] bool has_active_animation_timer() const;
void animate(DOM::Document&);
private:
virtual void decoded_image_data_did_update() override { notify_image_style_values_did_update(); }
void on_decoded_image_data_loaded(DOM::Document&);
void on_decoded_image_data_loaded();
void notify_image_style_values_did_update();
void start_animation_timer_if_needed(DOM::Document&);
void stop_animation_timer();
bool is_animatable() const;
bool animation_has_completed() const;
int current_frame_duration() const;
GC::Ref<HTML::SharedResourceRequest> m_resource_request;
GC::Ptr<Platform::Timer> m_timer;
HashTable<ImageStyleValue const*> m_image_style_values;
size_t m_current_frame_index { 0 };
size_t m_loops_completed { 0 };
};
class ImageStyleValue final
@ -88,7 +76,6 @@ public:
static ValueComparingNonnullRefPtr<ImageStyleValue const> create(URL const&, Optional<::URL::URL> style_resource_base_url);
static ValueComparingNonnullRefPtr<ImageStyleValue const> create(::URL::URL const&);
virtual ~ImageStyleValue() override;
static u64 active_animation_timer_count(DOM::Document const&);
virtual void serialize(StringBuilder&, SerializationMode) const override;
virtual bool equals(StyleValue const& other) const override;
@ -106,7 +93,6 @@ public:
virtual Optional<Gfx::Color> color_if_single_pixel_bitmap(DOM::Document const&) const override;
Optional<Gfx::DecodedImageFrame> current_frame(DOM::Document const&, DevicePixelRect const& dest_rect = {}) const;
size_t current_frame_index(DOM::Document const&) const;
GC::Ptr<HTML::DecodedImageData> image_data(DOM::Document const&) const;

View file

@ -6798,22 +6798,6 @@ void Document::remove_css_image_resource_if_unused(URL::URL const& url)
m_css_image_resources.remove(it);
}
void Document::animate_css_image_resource(URL::URL const& url)
{
if (auto* resource = css_image_resource(url))
resource->animate(*this);
}
u64 Document::active_css_image_animation_timer_count() const
{
u64 count = 0;
for (auto const& it : m_css_image_resources) {
if (it.value->has_active_animation_timer())
++count;
}
return count;
}
void Document::prune_image_resource_caches()
{
static constexpr size_t decoded_image_resource_cache_limit = 8 * MiB;

View file

@ -831,8 +831,6 @@ public:
CSS::ImageStyleValueResource const* css_image_resource(URL::URL const&) const;
CSS::ImageStyleValueResource& create_css_image_resource(GC::Ref<HTML::SharedResourceRequest>);
void remove_css_image_resource_if_unused(URL::URL const&);
void animate_css_image_resource(URL::URL const&);
u64 active_css_image_animation_timer_count() const;
void prune_image_resource_caches();
void restore_the_history_object_state(NonnullRefPtr<HTML::SessionHistoryEntry> entry);

View file

@ -9,11 +9,14 @@
#include <LibGfx/Bitmap.h>
#include <LibJS/Runtime/ExternalMemory.h>
#include <LibJS/Runtime/Realm.h>
#include <LibJS/Runtime/VM.h>
#include <LibWeb/CSS/ComputedValues.h>
#include <LibWeb/DOM/DocumentObserver.h>
#include <LibWeb/HTML/AnimatedBitmapDecodedImageData.h>
#include <LibWeb/Painting/DisplayListRecorder.h>
#include <LibWeb/Painting/DisplayListRecordingContext.h>
#include <LibWeb/Platform/ImageCodecPlugin.h>
#include <LibWeb/Platform/Timer.h>
namespace Web::HTML {
@ -55,6 +58,7 @@ void AnimatedBitmapDecodedImageData::deliver_frames_for_session(i64 session_id,
GC::Ref<AnimatedBitmapDecodedImageData> AnimatedBitmapDecodedImageData::create(
JS::Realm& realm,
DOM::Document& document,
i64 session_id,
u32 frame_count,
u32 loop_count,
@ -63,8 +67,11 @@ GC::Ref<AnimatedBitmapDecodedImageData> AnimatedBitmapDecodedImageData::create(
Vector<u32> durations,
Vector<NonnullRefPtr<Gfx::Bitmap>> initial_bitmaps)
{
auto animation_timer = Platform::Timer::create(realm.heap());
auto document_observer = realm.create<DOM::DocumentObserver>(realm, document);
auto data = realm.create<AnimatedBitmapDecodedImageData>(
session_id, frame_count, loop_count, size, move(color_space), move(durations));
session_id, frame_count, loop_count, size, move(color_space), move(durations), animation_timer, document_observer);
// Place initial bitmaps into the buffer pool.
for (u32 i = 0; i < initial_bitmaps.size(); ++i) {
@ -89,18 +96,32 @@ AnimatedBitmapDecodedImageData::AnimatedBitmapDecodedImageData(
u32 loop_count,
Gfx::IntSize size,
Gfx::ColorSpace color_space,
Vector<u32> durations)
: m_session_id(session_id)
Vector<u32> durations,
GC::Ref<Platform::Timer> animation_timer,
GC::Ref<DOM::DocumentObserver> document_observer)
: AnimatedDecodedImageData(document_observer)
, m_session_id(session_id)
, m_frame_count(frame_count)
, m_loop_count(loop_count)
, m_size(size)
, m_color_space(move(color_space))
, m_durations(move(durations))
, m_animation_timer(animation_timer)
{
m_animation_timer->on_timeout = GC::create_function(vm().heap(), [weak_this = GC::Weak { *this }] {
if (auto self = weak_this.ptr())
self->advance_animation();
});
}
AnimatedBitmapDecodedImageData::~AnimatedBitmapDecodedImageData() = default;
void AnimatedBitmapDecodedImageData::visit_edges(Cell::Visitor& visitor)
{
Base::visit_edges(visitor);
visitor.visit(m_animation_timer);
}
size_t AnimatedBitmapDecodedImageData::external_memory_size() const
{
size_t size = JS::vector_external_memory_size(m_durations);
@ -114,10 +135,56 @@ size_t AnimatedBitmapDecodedImageData::external_memory_size() const
void AnimatedBitmapDecodedImageData::finalize()
{
Base::finalize();
m_animation_timer->stop();
session_registry().remove(m_session_id);
Platform::ImageCodecPlugin::the().stop_animation_decode(m_session_id);
}
bool AnimatedBitmapDecodedImageData::animation_has_completed() const
{
return m_loop_count > 0 && m_loops_completed == m_loop_count;
}
void AnimatedBitmapDecodedImageData::reset_animation()
{
m_current_frame_index = 0;
m_loops_completed = 0;
maybe_request_more_frames(m_current_frame_index);
notify_clients_did_update();
}
void AnimatedBitmapDecodedImageData::start_animation()
{
// NB: We should only ever start the animation when the first client is registered, or when animation restarts, both
// of which should guarantee that we are at the beginning of the animation.
VERIFY(m_current_frame_index == 0 && m_loops_completed == 0);
m_animation_timer->start(frame_duration(0));
}
void AnimatedBitmapDecodedImageData::stop_animation()
{
m_animation_timer->stop();
}
void AnimatedBitmapDecodedImageData::advance_animation()
{
m_current_frame_index = (m_current_frame_index + 1) % m_frame_count;
maybe_request_more_frames(m_current_frame_index);
auto current_frame_duration = frame_duration(m_current_frame_index);
if (current_frame_duration != m_animation_timer->interval())
m_animation_timer->restart(current_frame_duration);
if (m_current_frame_index == m_frame_count - 1) {
++m_loops_completed;
if (animation_has_completed())
stop_animation();
}
notify_clients_did_update();
}
AnimatedBitmapDecodedImageData::BufferSlot const* AnimatedBitmapDecodedImageData::find_slot(u32 frame_index) const
{
for (auto const& slot : m_buffer_slots) {
@ -159,6 +226,11 @@ Optional<Gfx::DecodedImageFrame> AnimatedBitmapDecodedImageData::default_frame(G
return frame(0, size);
}
Optional<Gfx::DecodedImageFrame> AnimatedBitmapDecodedImageData::current_frame(Gfx::IntSize size) const
{
return frame(m_current_frame_index, size);
}
int AnimatedBitmapDecodedImageData::frame_duration(size_t frame_index) const
{
if (frame_index >= m_durations.size())
@ -181,9 +253,9 @@ Optional<CSSPixelFraction> AnimatedBitmapDecodedImageData::intrinsic_aspect_rati
return CSSPixels(m_size.width()) / CSSPixels(m_size.height());
}
void AnimatedBitmapDecodedImageData::paint(DisplayListRecordingContext& context, size_t frame_index, Gfx::IntRect dst_rect, CSS::ImageRendering image_rendering) const
void AnimatedBitmapDecodedImageData::paint(DisplayListRecordingContext& context, Gfx::IntRect dst_rect, CSS::ImageRendering image_rendering) const
{
auto decoded_frame = frame(frame_index);
auto decoded_frame = current_frame();
if (!decoded_frame.has_value())
return;
@ -212,23 +284,14 @@ void AnimatedBitmapDecodedImageData::receive_frames(Vector<NonnullRefPtr<Gfx::Bi
}
}
size_t AnimatedBitmapDecodedImageData::notify_frame_advanced(size_t caller_frame_index)
{
// We own the frame progression. Only advance when a caller reports
// the expected next frame (this deduplicates multiple callers per tick).
size_t expected_next = (m_current_frame_index + 1) % m_frame_count;
if (caller_frame_index == expected_next) {
m_current_frame_index = expected_next;
maybe_request_more_frames(m_current_frame_index);
}
return m_current_frame_index;
}
void AnimatedBitmapDecodedImageData::maybe_request_more_frames(size_t current_frame_index)
{
if (m_request_in_flight)
return;
// TODO: Once all `ImageProvider`s are `DecodedImageData::Client`s we can defer loading new frames if we have no
// clients
// Count how many frames ahead of current are in the pool.
u32 frames_ahead = 0;
for (u32 offset = 1; offset <= BUFFER_POOL_SIZE; ++offset) {

View file

@ -12,19 +12,22 @@
#include <LibGfx/ColorSpace.h>
#include <LibGfx/DecodedImageFrame.h>
#include <LibGfx/Forward.h>
#include <LibWeb/HTML/AnimatedDecodedImageData.h>
#include <LibWeb/HTML/DecodedImageData.h>
namespace Web::HTML {
class AnimatedBitmapDecodedImageData final : public DecodedImageData {
GC_CELL(AnimatedBitmapDecodedImageData, DecodedImageData);
class AnimatedBitmapDecodedImageData final : public AnimatedDecodedImageData {
GC_CELL(AnimatedBitmapDecodedImageData, AnimatedDecodedImageData);
GC_DECLARE_ALLOCATOR(AnimatedBitmapDecodedImageData);
friend class Web::Internals::Internals;
public:
static constexpr bool OVERRIDES_FINALIZE = true;
static GC::Ref<AnimatedBitmapDecodedImageData> create(
JS::Realm&,
DOM::Document&,
i64 session_id,
u32 frame_count,
u32 loop_count,
@ -35,22 +38,16 @@ public:
virtual ~AnimatedBitmapDecodedImageData() override;
virtual void finalize() override;
virtual void visit_edges(Cell::Visitor&) override;
virtual Optional<Gfx::DecodedImageFrame> default_frame(Gfx::IntSize = {}) const override;
virtual Optional<Gfx::DecodedImageFrame> frame(size_t frame_index, Gfx::IntSize = {}) const override;
virtual int frame_duration(size_t frame_index) const override;
virtual size_t frame_count() const override { return m_frame_count; }
virtual size_t loop_count() const override { return m_loop_count; }
virtual bool is_animated() const override { return true; }
virtual Optional<Gfx::DecodedImageFrame> current_frame(Gfx::IntSize = {}) const override;
virtual Optional<CSSPixels> intrinsic_width() const override;
virtual Optional<CSSPixels> intrinsic_height() const override;
virtual Optional<CSSPixelFraction> intrinsic_aspect_ratio() const override;
virtual void paint(DisplayListRecordingContext&, size_t frame_index, Gfx::IntRect dst_rect, CSS::ImageRendering) const override;
virtual size_t notify_frame_advanced(size_t caller_frame_index) override;
virtual void paint(DisplayListRecordingContext&, Gfx::IntRect dst_rect, CSS::ImageRendering) const override;
void receive_frames(Vector<NonnullRefPtr<Gfx::Bitmap>>, u32 start_frame_index);
@ -77,10 +74,22 @@ private:
u32 loop_count,
Gfx::IntSize,
Gfx::ColorSpace,
Vector<u32> durations);
Vector<u32> durations,
GC::Ref<Platform::Timer>,
GC::Ref<DOM::DocumentObserver>);
virtual size_t external_memory_size() const override;
virtual void reset_animation() override;
virtual void start_animation() override;
virtual void stop_animation() override;
void advance_animation();
bool animation_has_completed() const;
int frame_duration(size_t frame_index) const;
Optional<Gfx::DecodedImageFrame> frame(size_t frame_index, Gfx::IntSize = {}) const;
BufferSlot const* find_slot(u32 frame_index) const;
BufferSlot& evict_oldest_slot();
void maybe_request_more_frames(size_t current_frame_index);
@ -98,6 +107,8 @@ private:
bool m_request_in_flight { false };
u32 m_current_frame_index { 0 };
u32 m_last_requested_start_frame { 0 };
u32 m_loops_completed { 0 };
GC::Ref<Platform::Timer> m_animation_timer;
};
}

View file

@ -0,0 +1,84 @@
/*
* Copyright (c) 2026, Callum Law <callumlaw1709@outlook.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include "AnimatedDecodedImageData.h"
#include <LibWeb/DOM/Document.h>
#include <LibWeb/DOM/DocumentObserver.h>
namespace Web::HTML {
void AnimatedDecodedImageData::visit_edges(Cell::Visitor& visitor)
{
Base::visit_edges(visitor);
visitor.visit(m_document_observer);
}
AnimatedDecodedImageData::AnimatedDecodedImageData(GC::Ref<DOM::DocumentObserver> document_observer)
: m_document_observer(document_observer)
{
auto weak_this = GC::Weak { *this };
// OPTIMIZATION: To avoid CPU churn in background tabs we cancel the animation when the document is inactive or
// hidden. Other browsers disagree on what should happen when the document becomes active again,
// Blink continues the animation from where it would be if it had been running the whole time, and
// Gecko restarts the animation. For now we restart the animation since that's simpler.
m_document_observer->set_document_became_inactive([weak_this] {
if (auto self = weak_this.ptr())
self->stop_animation();
});
m_document_observer->set_document_became_active([weak_this] {
if (auto self = weak_this.ptr()) {
if (!self->has_clients())
self->m_should_start_animation_on_client_registration = true;
self->restart_animation();
}
});
m_document_observer->set_document_visibility_state_observer([weak_this](HTML::VisibilityState visibility_state) {
if (auto self = weak_this.ptr()) {
switch (visibility_state) {
case HTML::VisibilityState::Hidden:
self->stop_animation();
break;
case HTML::VisibilityState::Visible:
if (!self->has_clients())
self->m_should_start_animation_on_client_registration = true;
self->restart_animation();
break;
}
}
});
}
void AnimatedDecodedImageData::restart_animation()
{
stop_animation();
reset_animation();
start_animation_if_needed();
}
void AnimatedDecodedImageData::on_client_registered()
{
if (!m_should_start_animation_on_client_registration)
return;
m_should_start_animation_on_client_registration = false;
start_animation_if_needed();
}
void AnimatedDecodedImageData::start_animation_if_needed()
{
// NB: Animations should start when the first client is registered while the document is active and visible.
if (!has_clients() || !m_document_observer->document()->is_fully_active() || m_document_observer->document()->hidden())
return;
start_animation();
}
}

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2026, Callum Law <callumlaw1709@outlook.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <LibWeb/HTML/DecodedImageData.h>
namespace Web::HTML {
class AnimatedDecodedImageData : public DecodedImageData {
GC_CELL(AnimatedDecodedImageData, DecodedImageData);
GC_DECLARE_ALLOCATOR(AnimatedDecodedImageData);
public:
virtual void visit_edges(Cell::Visitor&) override;
virtual void restart_animation() override;
protected:
AnimatedDecodedImageData(GC::Ref<DOM::DocumentObserver>);
virtual void start_animation() = 0;
virtual void reset_animation() = 0;
virtual void stop_animation() = 0;
virtual void on_client_registered() override;
private:
void start_animation_if_needed();
GC::Ref<DOM::DocumentObserver> m_document_observer;
bool m_should_start_animation_on_client_registration { true };
};
}

View file

@ -34,7 +34,7 @@ size_t BitmapDecodedImageData::external_memory_size() const
return m_frame.bitmap().data_size();
}
Optional<Gfx::DecodedImageFrame> BitmapDecodedImageData::frame(size_t, Gfx::IntSize) const
Optional<Gfx::DecodedImageFrame> BitmapDecodedImageData::current_frame(Gfx::IntSize) const
{
return m_frame;
}
@ -59,7 +59,7 @@ Optional<CSSPixelFraction> BitmapDecodedImageData::intrinsic_aspect_ratio() cons
return CSSPixels(m_frame.width()) / CSSPixels(m_frame.height());
}
void BitmapDecodedImageData::paint(DisplayListRecordingContext& context, size_t, Gfx::IntRect dst_rect, CSS::ImageRendering image_rendering) const
void BitmapDecodedImageData::paint(DisplayListRecordingContext& context, Gfx::IntRect dst_rect, CSS::ImageRendering image_rendering) const
{
auto scaling_mode = CSS::to_gfx_scaling_mode(image_rendering, m_frame.size(), dst_rect.size());

View file

@ -21,18 +21,13 @@ public:
virtual ~BitmapDecodedImageData() override;
virtual Optional<Gfx::DecodedImageFrame> default_frame(Gfx::IntSize = {}) const override;
virtual Optional<Gfx::DecodedImageFrame> frame(size_t frame_index, Gfx::IntSize = {}) const override;
virtual int frame_duration(size_t) const override { return 0; }
virtual size_t frame_count() const override { return 1; }
virtual size_t loop_count() const override { return 0; }
virtual bool is_animated() const override { return false; }
virtual Optional<Gfx::DecodedImageFrame> current_frame(Gfx::IntSize = {}) const override;
virtual Optional<CSSPixels> intrinsic_width() const override;
virtual Optional<CSSPixels> intrinsic_height() const override;
virtual Optional<CSSPixelFraction> intrinsic_aspect_ratio() const override;
virtual void paint(DisplayListRecordingContext&, size_t frame_index, Gfx::IntRect dst_rect, CSS::ImageRendering) const override;
virtual void paint(DisplayListRecordingContext&, Gfx::IntRect dst_rect, CSS::ImageRendering) const override;
private:
BitmapDecodedImageData(Gfx::DecodedImageFrame&& frame);

View file

@ -16,6 +16,8 @@ void DecodedImageData::Client::register_with_decoded_image_data_if_needed()
return;
image_data->m_clients.set(this);
image_data->on_client_registered();
}
void DecodedImageData::Client::unregister_with_decoded_image_data_if_needed()

View file

@ -20,6 +20,7 @@ namespace Web::HTML {
// https://html.spec.whatwg.org/multipage/images.html#img-req-data
class DecodedImageData : public JS::Cell {
GC_CELL(DecodedImageData, JS::Cell);
friend class Web::Internals::Internals;
public:
class Client {
@ -37,17 +38,12 @@ public:
[[nodiscard]] bool is_cors_cross_origin() const { return m_is_cors_cross_origin; }
void set_is_cors_cross_origin(bool value) { m_is_cors_cross_origin = value; }
virtual void paint([[maybe_unused]] DisplayListRecordingContext&, [[maybe_unused]] size_t frame_index, [[maybe_unused]] Gfx::IntRect dst_rect, CSS::ImageRendering) const = 0;
virtual void paint([[maybe_unused]] DisplayListRecordingContext&, [[maybe_unused]] Gfx::IntRect dst_rect, CSS::ImageRendering) const = 0;
virtual Optional<Gfx::DecodedImageFrame> default_frame(Gfx::IntSize = {}) const = 0;
virtual Optional<Gfx::DecodedImageFrame> frame(size_t frame_index, Gfx::IntSize = {}) const = 0;
virtual int frame_duration(size_t frame_index) const = 0;
virtual Optional<Gfx::DecodedImageFrame> current_frame(Gfx::IntSize = {}) const = 0;
virtual size_t frame_count() const = 0;
virtual size_t loop_count() const = 0;
virtual bool is_animated() const = 0;
virtual size_t notify_frame_advanced(size_t frame_index) { return frame_index; }
virtual void restart_animation() { }
virtual Optional<CSSPixels> intrinsic_width() const = 0;
virtual Optional<CSSPixels> intrinsic_height() const = 0;
@ -57,6 +53,8 @@ protected:
DecodedImageData();
void notify_clients_did_update();
bool has_clients() const { return !m_clients.is_empty(); }
virtual void on_client_registered() { }
private:
HashTable<Client*> m_clients;

View file

@ -148,9 +148,6 @@ GC_DEFINE_ALLOCATOR(HTMLImageElement);
HTMLImageElement::HTMLImageElement(DOM::Document& document, DOM::QualifiedName qualified_name)
: HTMLElement(document, move(qualified_name))
{
m_animation_timer = Core::Timer::create();
m_animation_timer->on_timeout = [this] { animate(); };
document.register_viewport_client(*this);
}
@ -171,25 +168,12 @@ void HTMLImageElement::initialize(JS::Realm& realm)
m_current_request = ImageRequest::create(realm, document().page());
// AD-HOC: Create a DocumentObserver eagerly to handle document lifecycle changes.
// The document_became_inactive callback handles the navigation case by clearing the
// load event delayer and stopping the animation timer.
// The document_became_inactive callback handles the navigation case by clearing the load event delayer.
// A document_became_active callback is set lazily by update_the_image_data() when
// needed to restart image loading after the document becomes active again.
m_document_observer = realm.create<DOM::DocumentObserver>(realm, document());
m_document_observer->set_document_became_inactive([this]() {
m_load_event_delayer.clear();
m_animation_timer->stop();
m_animation_paused_by_visibility = false;
});
m_document_observer->set_document_visibility_state_observer([this](HTML::VisibilityState visibility_state) {
if (visibility_state == HTML::VisibilityState::Hidden) {
m_animation_paused_by_visibility = m_animation_timer->is_active();
m_animation_timer->stop();
return;
}
if (m_animation_paused_by_visibility)
start_animation_timer_if_visible();
});
}
@ -206,6 +190,8 @@ void HTMLImageElement::adopted_from(DOM::Document& old_document)
if (m_load_event_delayer.has_value())
m_load_event_delayer.emplace(document());
// FIXME: The current and pending requests may still be pointing at the old document's SharedResourceRequests.
}
void HTMLImageElement::visit_edges(Cell::Visitor& visitor)
@ -699,7 +685,6 @@ void HTMLImageElement::update_the_image_data_impl(bool restart_animations, bool
m_current_request = ImageRequest::create(document().realm(), document().page());
m_current_request->set_image_data(entry->image_data);
m_current_request->set_state(ImageRequest::State::CompletelyAvailable);
m_current_frame_index = 0;
register_with_decoded_image_data_if_needed();
// 5. Prepare the current request for presentation given the img element.
@ -985,13 +970,6 @@ void HTMLImageElement::add_callbacks_to_image_request(GC::Ref<ImageRequest> imag
if (!maybe_omit_events || previous_url != url_string)
dispatch_event(DOM::Event::create(realm(), HTML::EventNames::load));
m_current_frame_index = 0;
m_animation_timer->stop();
if (image_data->is_animated() && image_data->frame_count() > 1) {
m_animation_timer->set_interval(image_data->frame_duration(0));
start_animation_timer_if_visible();
}
m_load_event_delayer.clear();
}));
},
@ -1230,38 +1208,8 @@ void HTMLImageElement::handle_failed_fetch()
// https://html.spec.whatwg.org/multipage/rendering.html#restart-the-animation
void HTMLImageElement::restart_the_animation()
{
m_current_frame_index = 0;
if (current_request_has_running_animation()) {
start_animation_timer_if_visible();
} else {
m_animation_timer->stop();
m_animation_paused_by_visibility = false;
}
}
bool HTMLImageElement::current_request_has_running_animation() const
{
auto image_data = m_current_request->image_data();
return image_data && image_data->is_animated() && image_data->frame_count() > 1;
}
void HTMLImageElement::start_animation_timer_if_visible()
{
if (!current_request_has_running_animation()) {
m_animation_timer->stop();
m_animation_paused_by_visibility = false;
return;
}
if (document().visibility_state_value() == VisibilityState::Hidden) {
m_animation_timer->stop();
m_animation_paused_by_visibility = true;
return;
}
m_animation_paused_by_visibility = false;
m_animation_timer->start();
if (auto image_data = m_current_request->image_data())
image_data->restart_animation();
}
// https://html.spec.whatwg.org/multipage/images.html#update-the-source-set
@ -1424,38 +1372,6 @@ void HTMLImageElement::set_source_set(SourceSet source_set)
m_source_set = move(source_set);
}
void HTMLImageElement::animate()
{
if (document().visibility_state_value() == VisibilityState::Hidden) {
m_animation_timer->stop();
m_animation_paused_by_visibility = true;
return;
}
auto image_data = m_current_request->image_data();
if (!image_data) {
return;
}
m_current_frame_index = (m_current_frame_index + 1) % image_data->frame_count();
m_current_frame_index = image_data->notify_frame_advanced(m_current_frame_index);
auto current_frame_duration = image_data->frame_duration(m_current_frame_index);
if (current_frame_duration != m_animation_timer->interval()) {
m_animation_timer->restart(current_frame_duration);
}
if (m_current_frame_index == image_data->frame_count() - 1) {
++m_loops_completed;
if (m_loops_completed > 0 && m_loops_completed == image_data->loop_count()) {
m_animation_timer->stop();
m_animation_paused_by_visibility = false;
}
}
set_needs_repaint();
}
bool HTMLImageElement::allows_auto_sizes() const
{
// An img element allows auto-sizes if:

View file

@ -98,8 +98,6 @@ public:
ImageRequest& current_request() { return *m_current_request; }
ImageRequest const& current_request() const { return *m_current_request; }
virtual size_t current_frame_index() const override { return m_current_frame_index; }
// https://html.spec.whatwg.org/multipage/images.html#upgrade-the-pending-request-to-the-current-request
void upgrade_pending_request_to_current_request();
@ -140,15 +138,6 @@ private:
virtual void decoded_image_data_did_update() override { set_needs_repaint(); }
bool current_request_has_running_animation() const;
void start_animation_timer_if_visible();
void animate();
RefPtr<Core::Timer> m_animation_timer;
size_t m_current_frame_index { 0 };
size_t m_loops_completed { 0 };
bool m_animation_paused_by_visibility { false };
Optional<DOM::DocumentLoadEventDelayer> m_load_event_delayer;
GC::Ptr<DOM::DocumentObserver> m_document_observer;

View file

@ -287,7 +287,6 @@ private:
virtual bool supports_dimension_attributes() const override { return type_state() == TypeAttributeState::ImageButton; }
// ^Layout::ImageProvider
virtual size_t current_frame_index() const override { return 0; }
virtual GC::Ptr<HTML::DecodedImageData> decoded_image_data() const override { return image_data(); }
virtual void initialize(JS::Realm&) override;

View file

@ -83,7 +83,6 @@ private:
virtual i32 default_tab_index_value() const override;
// ^Layout::ImageProvider
virtual size_t current_frame_index() const override { return 0; }
virtual GC::Ptr<DecodedImageData> decoded_image_data() const override { return image_data(); }
GC::Ptr<DecodedImageData> image_data() const;

View file

@ -221,6 +221,7 @@ void SharedResourceRequest::handle_successful_fetch(URL::URL const& url_string,
strong_this->m_image_data = AnimatedBitmapDecodedImageData::create(
strong_this->m_document->realm(),
*strong_this->m_document,
result.session_id,
result.frame_count,
result.loop_count,

View file

@ -24,7 +24,6 @@
#include <LibWeb/Bindings/MainThreadVM.h>
#include <LibWeb/CSS/CSSStyleSheet.h>
#include <LibWeb/CSS/PreferredColorScheme.h>
#include <LibWeb/CSS/StyleValues/ImageStyleValue.h>
#include <LibWeb/Compositor/AsyncScrollTree.h>
#include <LibWeb/Compositor/AsyncScrollingState.h>
#include <LibWeb/DOM/Document.h>
@ -35,6 +34,7 @@
#include <LibWeb/Dump.h>
#include <LibWeb/Fetch/Fetching/Fetching.h>
#include <LibWeb/Geometry/DOMRect.h>
#include <LibWeb/HTML/AnimatedBitmapDecodedImageData.h>
#include <LibWeb/HTML/BrowsingContext.h>
#include <LibWeb/HTML/EventLoop/EventLoop.h>
#include <LibWeb/HTML/EventLoop/TaskQueue.h>
@ -43,6 +43,7 @@
#include <LibWeb/HTML/Navigable.h>
#include <LibWeb/HTML/Scripting/TemporaryExecutionContext.h>
#include <LibWeb/HTML/SessionHistoryEntry.h>
#include <LibWeb/HTML/SharedResourceRequest.h>
#include <LibWeb/HTML/TraversableNavigable.h>
#include <LibWeb/HTML/Window.h>
#include <LibWeb/Internals/InternalGamepad.h>
@ -55,6 +56,7 @@
#include <LibWeb/Painting/DisplayListResourceStorage.h>
#include <LibWeb/Painting/PaintableBox.h>
#include <LibWeb/Painting/ViewportPaintable.h>
#include <LibWeb/WebIDL/ExceptionOr.h>
#include <LibWeb/WebIDL/Promise.h>
namespace Web::Internals {
@ -856,9 +858,38 @@ bool Internals::style_sheet_may_have_has_selectors(CSS::CSSStyleSheet& style_she
return style_sheet.selector_insights().has_has_selectors;
}
WebIDL::UnsignedLongLong Internals::active_image_style_value_animation_count()
WebIDL::ExceptionOr<JS::Object*> Internals::image_animation_state_for_url(String const& url)
{
return CSS::ImageStyleValue::active_animation_timer_count(window().associated_document());
auto& document = window().associated_document();
auto parsed_url = document.encoding_parse_url(url);
if (!parsed_url.has_value())
return WebIDL::SimpleException { .type = WebIDL::SimpleExceptionType::TypeError, .message = MUST(String::formatted("Invalid URL: '{}'", url)) };
auto it = document.shared_resource_requests().find(*parsed_url);
if (it == document.shared_resource_requests().end())
return WebIDL::SimpleException { .type = WebIDL::SimpleExceptionType::TypeError, .message = MUST(String::formatted("URL doesn't have any associated shared resource requests: '{}'", url)) };
auto image_data = it->value->image_data();
if (!image_data)
return WebIDL::SimpleException { .type = WebIDL::SimpleExceptionType::TypeError, .message = MUST(String::formatted("URL's shared resource request doesn't have any associated image data: '{}'", url)) };
auto const* animated_bitmap_data = as_if<HTML::AnimatedBitmapDecodedImageData>(*image_data);
if (!animated_bitmap_data)
return WebIDL::SimpleException { .type = WebIDL::SimpleExceptionType::TypeError, .message = MUST(String::formatted("URL's associated image is not an animated bitmap: '{}'", url)) };
auto object = JS::Object::create(realm(), nullptr);
object->define_direct_property("timerActive"_utf16_fly_string, JS::Value(animated_bitmap_data->m_animation_timer->is_active()), JS::default_attributes);
object->define_direct_property("sessionID"_utf16_fly_string, JS::Value(static_cast<double>(animated_bitmap_data->m_session_id)), JS::default_attributes);
object->define_direct_property("frameIndex"_utf16_fly_string, JS::Value(animated_bitmap_data->m_current_frame_index), JS::default_attributes);
object->define_direct_property("frameCount"_utf16_fly_string, JS::Value(animated_bitmap_data->m_frame_count), JS::default_attributes);
object->define_direct_property("loopsCompleted"_utf16_fly_string, JS::Value(animated_bitmap_data->m_loops_completed), JS::default_attributes);
object->define_direct_property("loopCount"_utf16_fly_string, JS::Value(animated_bitmap_data->m_loop_count), JS::default_attributes);
object->define_direct_property("clientCount"_utf16_fly_string, JS::Value(image_data->m_clients.size()), JS::default_attributes);
return object.ptr();
}
struct AsyncScrollingStateSnapshot {

View file

@ -136,7 +136,7 @@ public:
void set_preferred_color_scheme(StringView color_scheme);
String canvas_color_scheme();
bool style_sheet_may_have_has_selectors(CSS::CSSStyleSheet&);
WebIDL::UnsignedLongLong active_image_style_value_animation_count();
WebIDL::ExceptionOr<JS::Object*> image_animation_state_for_url(String const& url);
JS::Object* async_scrolling_state();
bool async_scrolling_state_blocks_wheel_event_at(double x, double y);
bool async_scrolling_state_can_wheel_scroll_at(double x, double y, double delta_x, double delta_y, bool force_stale_wheel_event_regions);

View file

@ -132,7 +132,7 @@ interface Internals {
DOMString canvasColorScheme();
// Returns the selector-insight cache state for stylesheet invalidation tests.
boolean styleSheetMayHaveHasSelectors(CSSStyleSheet sheet);
unsigned long long activeImageStyleValueAnimationCount();
object imageAnimationStateForURL(USVString url);
object asyncScrollingState();
boolean asyncScrollingStateBlocksWheelEventAt(double x, double y);

View file

@ -50,7 +50,7 @@ Optional<CSSPixelSize> ImageProvider::intrinsic_size() const
Optional<Gfx::DecodedImageFrame> ImageProvider::current_image_frame(Optional<Gfx::IntSize> size) const
{
if (auto const& data = decoded_image_data())
return data->frame(current_frame_index(), size.value_or(intrinsic_size().value_or({}).to_type<int>()));
return data->current_frame(size.value_or(intrinsic_size().value_or({}).to_type<int>()));
return {};
}

View file

@ -22,8 +22,6 @@ public:
bool is_image_available() const { return decoded_image_data() != nullptr; }
virtual size_t current_frame_index() const = 0;
virtual GC::Ptr<HTML::DecodedImageData> decoded_image_data() const = 0;
Optional<CSSPixels> intrinsic_width() const;

View file

@ -219,13 +219,6 @@ public:
m_layout_node = layout_node;
}
virtual size_t current_frame_index() const override
{
if (auto document = this->document())
return m_image->current_frame_index(*document);
return 0;
}
virtual GC::Ptr<HTML::DecodedImageData> decoded_image_data() const override
{
if (auto document = this->document())

View file

@ -85,7 +85,7 @@ void ImagePaintable::paint(DisplayListRecordingContext& context, PaintPhase phas
context.display_list_recorder().save();
context.display_list_recorder().add_clip_rect(image_int_rect_device_pixels);
}
decoded_image_data->paint(context, m_image_provider.current_frame_index(), draw_rect, computed_values().image_rendering());
decoded_image_data->paint(context, draw_rect, computed_values().image_rendering());
if (draw_rect_needs_clip)
context.display_list_recorder().restore();
}

View file

@ -221,7 +221,7 @@ RefPtr<Gfx::PaintingSurface> SVGDecodedImageData::render_to_surface(Gfx::IntSize
return surface;
}
Optional<Gfx::DecodedImageFrame> SVGDecodedImageData::frame(size_t, Gfx::IntSize size) const
Optional<Gfx::DecodedImageFrame> SVGDecodedImageData::current_frame(Gfx::IntSize size) const
{
if (size.is_empty())
return {};
@ -243,7 +243,7 @@ Optional<Gfx::DecodedImageFrame> SVGDecodedImageData::default_frame(Gfx::IntSize
{
// FIXME: Implement this properly once we support animated SVGs, potentially by creating a temporary internal
// document which has animations disabled.
return frame(0, size);
return current_frame(size);
}
Optional<CSSPixels> SVGDecodedImageData::intrinsic_width() const
@ -300,7 +300,7 @@ void SVGDecodedImageData::SVGPageClient::visit_edges(Visitor& visitor)
visitor.visit(m_svg_page);
}
void SVGDecodedImageData::paint(DisplayListRecordingContext& context, size_t, Gfx::IntRect dst_rect, CSS::ImageRendering) const
void SVGDecodedImageData::paint(DisplayListRecordingContext& context, Gfx::IntRect dst_rect, CSS::ImageRendering) const
{
auto display_list = record_display_list(dst_rect.size(), context.display_list_recorder().resource_storage());
if (!display_list.has_value())

View file

@ -25,24 +25,19 @@ public:
virtual ~SVGDecodedImageData() override;
virtual Optional<Gfx::DecodedImageFrame> default_frame(Gfx::IntSize = {}) const override;
virtual Optional<Gfx::DecodedImageFrame> frame(size_t frame_index, Gfx::IntSize) const override;
virtual Optional<Gfx::DecodedImageFrame> current_frame(Gfx::IntSize = {}) const override;
virtual Optional<CSSPixels> intrinsic_width() const override;
virtual Optional<CSSPixels> intrinsic_height() const override;
virtual Optional<CSSPixelFraction> intrinsic_aspect_ratio() const override;
// FIXME: Support SVG animations. :^)
virtual int frame_duration(size_t) const override { return 0; }
virtual size_t frame_count() const override { return 1; }
virtual size_t loop_count() const override { return 0; }
virtual bool is_animated() const override { return false; }
DOM::Document const& svg_document() const { return *m_document; }
virtual void visit_edges(Cell::Visitor& visitor) override;
virtual size_t external_memory_size() const override;
virtual void paint(DisplayListRecordingContext&, size_t frame_index, Gfx::IntRect dst_rect, CSS::ImageRendering) const override;
virtual void paint(DisplayListRecordingContext&, Gfx::IntRect dst_rect, CSS::ImageRendering) const override;
private:
SVGDecodedImageData(GC::Ref<Page>, GC::Ref<SVGPageClient>, GC::Ref<DOM::Document>, GC::Ref<SVG::SVGSVGElement>);

View file

@ -5,7 +5,6 @@
*/
#include "SVGImageElement.h"
#include <LibCore/Timer.h>
#include <LibGC/Heap.h>
#include <LibGfx/DecodedImageFrame.h>
#include <LibWeb/Bindings/SVGImageElement.h>
@ -26,8 +25,6 @@ GC_DEFINE_ALLOCATOR(SVGImageElement);
SVGImageElement::SVGImageElement(DOM::Document& document, DOM::QualifiedName qualified_name)
: SVGGraphicsElement(document, move(qualified_name))
{
m_animation_timer = Core::Timer::create();
m_animation_timer->on_timeout = [this] { animate(); };
}
SVGImageElement::~SVGImageElement() = default;
@ -196,12 +193,6 @@ void SVGImageElement::fetch_the_document(URL::URL const& url)
m_resource_request->add_callbacks(
[this, resource_request = GC::Root { m_resource_request }] {
m_load_event_delayer.clear();
auto image_data = resource_request->image_data();
if (image_data->is_animated() && image_data->frame_count() > 1) {
m_current_frame_index = 0;
m_animation_timer->set_interval(image_data->frame_duration(0));
m_animation_timer->start();
}
register_with_decoded_image_data_if_needed();
set_needs_style_update(true);
set_needs_layout_update(DOM::SetNeedsLayoutReason::SVGImageElementFetchTheDocument);
@ -226,31 +217,6 @@ RefPtr<Layout::Node> SVGImageElement::create_layout_node(CSS::ComputedProperties
return make_ref_counted<Layout::SVGImageBox>(document(), *this, style);
}
void SVGImageElement::animate()
{
auto image_data = m_resource_request->image_data();
if (!image_data) {
return;
}
m_current_frame_index = (m_current_frame_index + 1) % image_data->frame_count();
auto current_frame_duration = image_data->frame_duration(m_current_frame_index);
if (current_frame_duration != m_animation_timer->interval()) {
m_animation_timer->restart(current_frame_duration);
}
if (m_current_frame_index == image_data->frame_count() - 1) {
++m_loops_completed;
if (m_loops_completed > 0 && m_loops_completed == image_data->loop_count()) {
m_animation_timer->stop();
}
}
if (paintable())
paintable()->set_needs_repaint();
}
GC::Ptr<HTML::DecodedImageData> SVGImageElement::decoded_image_data() const
{
if (!m_resource_request)

View file

@ -38,7 +38,6 @@ public:
Gfx::FloatRect bounding_box() const;
// ^Layout::ImageProvider
virtual size_t current_frame_index() const override { return m_current_frame_index; }
virtual GC::Ptr<HTML::DecodedImageData> decoded_image_data() const override;
protected:
@ -57,17 +56,11 @@ private:
virtual RefPtr<Layout::Node> create_layout_node(CSS::ComputedProperties const&) override;
virtual void decoded_image_data_did_update() override { set_needs_repaint(); }
void animate();
GC::Ptr<SVG::SVGAnimatedLength> m_x;
GC::Ptr<SVG::SVGAnimatedLength> m_y;
GC::Ptr<SVG::SVGAnimatedLength> m_width;
GC::Ptr<SVG::SVGAnimatedLength> m_height;
RefPtr<Core::Timer> m_animation_timer;
size_t m_current_frame_index { 0 };
size_t m_loops_completed { 0 };
Optional<URL::URL> m_href;
GC::Ptr<HTML::SharedResourceRequest> m_resource_request;

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

View file

@ -0,0 +1,44 @@
<!doctype html>
<html class="reftest-wait">
<style>
body {
margin: 0;
}
#container {
display: flex;
gap: 4px;
}
.target {
width: 100px;
height: 50px;
background: url("../data/red-then-green.gif") no-repeat;
}
</style>
<div id="container">
<div class="target"></div>
</div>
<script src="../../Text/input/include.js"></script>
<script>
(async () => {
const imageURL = "../data/red-then-green.gif";
// Wait for the animation to start.
await waitForImageAnimationState(imageURL, state => state.timerActive);
// Wait for half of the first frame's duration
await timeout(250);
// Add a new consumer. It's frame timings should be aligned with the first consumer.
const lateTarget = document.createElement("div");
lateTarget.className = "target";
container.append(lateTarget);
// Wait for the animation to advance to the second frame. Both consumers should be showing the second frame
await waitForImageAnimationState(imageURL, state => state.frameIndex > 0);
document.documentElement.classList.remove("reftest-wait");
})();
</script>
</html>

View file

@ -13,27 +13,16 @@
}
</style>
<div id="target"></div>
<script src="../../Text/input/include.js"></script>
<script>
const waitForAnimationToStart = () =>
new Promise(resolve => {
const wait = () => {
if (internals.activeImageStyleValueAnimationCount() > 0) {
resolve();
return;
}
requestAnimationFrame(wait);
};
wait();
});
(async () => {
const imageURL = "../data/red-then-green.gif";
// Wait for the animation to start
await waitForAnimationToStart();
await waitForImageAnimationState(imageURL, state => state.timerActive);
// Wait until the next frame
await new Promise(resolve => setTimeout(resolve, 600));
await waitForImageAnimationState(imageURL, state => state.frameIndex > 0);
// Take a screenshot - the image should have been invalidated and repainted.
document.documentElement.classList.remove("reftest-wait");

View file

@ -15,6 +15,7 @@
}
</style>
<div id="target"></div>
<script src="../../Text/input/include.js"></script>
<script>
const gifURL = "../data/animated-mask-image.gif";
@ -22,26 +23,12 @@
.getElementById("target")
.style.setProperty("mask-image", `linear-gradient(transparent, transparent), url("${gifURL}")`);
const waitForAnimationToStart = () =>
new Promise(resolve => {
const wait = () => {
if (internals.activeImageStyleValueAnimationCount() > 0) {
resolve();
return;
}
requestAnimationFrame(wait);
};
wait();
});
(async () => {
// Wait for the animation to start
await waitForAnimationToStart();
await waitForImageAnimationState(gifURL, state => state.timerActive);
// Wait until the next frame
await new Promise(resolve => setTimeout(resolve, 600));
await waitForImageAnimationState(gifURL, state => state.frameIndex > 0);
// Take a screenshot - the mask image should have been invalidated and repainted.
document.documentElement.classList.remove("reftest-wait");

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

@ -0,0 +1,2 @@
timer stopped after iframe removal: true
animation restarted after iframe reattach: true

View file

@ -0,0 +1,2 @@
timer active for shared animation: true
client count: 1

View file

@ -1,2 +1,2 @@
top document active animations: 0
iframe document active animations: 1
top document has animation state: false
iframe document timer active: true

View file

@ -0,0 +1,2 @@
timer active before base change: true
timer active after removal: true

View file

@ -0,0 +1,3 @@
timer active before teardown: true
timer active after teardown: true
timer active after hiding: true

View file

@ -0,0 +1,3 @@
timer active before replacement: true
timer active after replacement: true
timer active after hiding: true

View file

@ -0,0 +1,2 @@
timer active before hiding: true
timer active after hiding: true

View file

@ -1,2 +0,0 @@
active animations before base change: 1
active animations after removal: 0

View file

@ -1,3 +0,0 @@
active animations before teardown: 1
active animations after teardown: 1
active animations after hiding: 0

View file

@ -1,3 +0,0 @@
active animations before replacement: 1
active animations after replacement: 1
active animations after hiding: 0

View file

@ -1,2 +0,0 @@
active animations before hiding: 1
active animations after hiding: 0

View file

@ -0,0 +1,2 @@
timer active before hiding: true
timer active after hiding: true

View file

@ -1,2 +0,0 @@
active animations before hiding: 1
active animations after hiding: 0

View file

@ -0,0 +1,5 @@
client count before restart: 2
frame advanced before restart: true
session unchanged after restart: true
animation restarted after src reset: true
client count after restart: 2

View file

@ -1,4 +1,4 @@
Computed URL before base change: url("../wpt-import/images/anim-gr.gif")
Computed URL after base change: url("../wpt-import/images/anim-gr.gif")
active animations before base change: 1
active animations after base change: 1
timer active before base change: true
timer active after base change: true

View file

@ -0,0 +1,31 @@
<!doctype html>
<script src="../include.js"></script>
<style>
#target {
width: 100px;
height: 50px;
background-image: url("../../data/loop-count-2.gif");
}
</style>
<div id="target"></div>
<script>
const imageURL = "../../data/loop-count-2.gif";
promiseTest(async () => {
console.log("A");
// Wait for the animation to start.
await waitForImageAnimationState(imageURL, state => state.timerActive);
console.log("B");
// Wait for the animation to complete.
await timeout(2500);
console.log("C");
println(internals.imageAnimationStateForURL(imageURL).timerActive ? "FAIL" : "PASS");
console.log("D");
});
</script>

View file

@ -0,0 +1,52 @@
<!doctype html>
<script src="../include.js"></script>
<script>
const imageURL = "../wpt-import/images/anim-gr.gif";
async function loadIframe(iframe) {
const loaded = new Promise(resolve => iframe.addEventListener("load", resolve, { once: true }));
document.body.append(iframe);
await loaded;
iframe.contentWindow.internals.dumpLayoutTree(iframe.contentDocument);
}
promiseTest(async () => {
const iframe = document.createElement("iframe");
iframe.srcdoc = `
<!DOCTYPE html>
<style>
#target {
width: 100px;
height: 50px;
background-image: url("../wpt-import/images/anim-gr.gif");
}
</style>
<div id="target"></div>
`;
await loadIframe(iframe);
const iframeWindow = iframe.contentWindow;
// Wait the animation to progress to the second frame
await waitForImageAnimationState(imageURL, state => state.timerActive && state.frameIndex > 0, iframeWindow);
// Make the iframe document inactive - this should stop the animation.
iframe.remove();
// Wait a tick for the iframe to become inactive
await timeout(0);
println(
`timer stopped after iframe removal: ${!iframeWindow.internals.imageAnimationStateForURL(imageURL).timerActive}`
);
// Make the iframe document active again - this should reset the animation.
await loadIframe(iframe);
const stateAfterReattach = iframe.contentWindow.internals.imageAnimationStateForURL(imageURL);
println(
`animation restarted after iframe reattach: ${stateAfterReattach.timerActive && stateAfterReattach.frameIndex === 0 && stateAfterReattach.loopsCompleted === 0}`
);
});
</script>

View file

@ -0,0 +1,24 @@
<!doctype html>
<script src="../include.js"></script>
<style>
.target {
width: 100px;
height: 50px;
background-image: url("../../data/loop-count-2.gif");
}
</style>
<div class="target"></div>
<div class="target"></div>
<script>
const imageURL = "../../data/loop-count-2.gif";
promiseTest(async () => {
await new Promise(resolve => window.addEventListener("load", resolve));
internals.dumpLayoutTree(document);
const state = internals.imageAnimationStateForURL(imageURL);
println(`timer active for shared animation: ${state.timerActive}`);
println(`client count: ${state.clientCount}`);
});
</script>

View file

@ -1,6 +1,8 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<script>
const imageURL = "../wpt-import/images/anim-gr.gif";
asyncTest(async done => {
const iframe = document.createElement("iframe");
const iframeLoaded = new Promise(resolve => iframe.addEventListener("load", resolve, { once: true }));
@ -10,7 +12,7 @@
#target {
width: 100px;
height: 50px;
background-image: url("../wpt-import/images/anim-gr.gif");
background-image: url("${imageURL}");
}
</style>
<div id="target"></div>
@ -20,8 +22,15 @@
internals.dumpLayoutTree(iframe.contentDocument);
println(`top document active animations: ${internals.activeImageStyleValueAnimationCount()}`);
println(`iframe document active animations: ${iframe.contentWindow.internals.activeImageStyleValueAnimationCount()}`);
let topDocumentHasAnimationState = true;
try {
internals.imageAnimationStateForURL(imageURL);
} catch {
topDocumentHasAnimationState = false;
}
println(`top document has animation state: ${topDocumentHasAnimationState}`);
println(`iframe document timer active: ${iframe.contentWindow.internals.imageAnimationStateForURL(imageURL).timerActive}`);
done();
});

View file

@ -9,14 +9,15 @@
"
></div>
<script>
const imageURL = new URL("../wpt-import/images/anim-gr.gif", document.baseURI).href;
asyncTest(async done => {
await new Promise(resolve => window.addEventListener("load", resolve));
internals.dumpLayoutTree(document);
const activeAnimationsBeforeBaseChange =
internals.activeImageStyleValueAnimationCount();
const timerActiveBeforeBaseChange = internals.imageAnimationStateForURL(imageURL).timerActive;
println(
`active animations before base change: ${activeAnimationsBeforeBaseChange}`
`timer active before base change: ${timerActiveBeforeBaseChange}`
);
const base = document.createElement("base");
@ -25,9 +26,8 @@
target.remove();
internals.dumpLayoutTree(document);
const activeAnimationsAfterRemoval =
internals.activeImageStyleValueAnimationCount();
println(`active animations after removal: ${activeAnimationsAfterRemoval}`);
const timerActiveAfterRemoval = internals.imageAnimationStateForURL(imageURL).timerActive;
println(`timer active after removal: ${timerActiveAfterRemoval}`);
done();
});
</script>

View file

@ -10,19 +10,21 @@
<div id="target"></div>
<dialog id="dialog">Hello</dialog>
<script>
const imageURL = "../wpt-import/images/anim-gr.gif";
asyncTest(async done => {
await new Promise(resolve => window.addEventListener("load", resolve));
internals.dumpLayoutTree(document);
println(`active animations before teardown: ${internals.activeImageStyleValueAnimationCount()}`);
println(`timer active before teardown: ${internals.imageAnimationStateForURL(imageURL).timerActive}`);
dialog.showModal();
internals.dumpLayoutTree(document);
println(`active animations after teardown: ${internals.activeImageStyleValueAnimationCount()}`);
println(`timer active after teardown: ${internals.imageAnimationStateForURL(imageURL).timerActive}`);
target.style.display = "none";
internals.dumpLayoutTree(document);
println(`active animations after hiding: ${internals.activeImageStyleValueAnimationCount()}`);
println(`timer active after hiding: ${internals.imageAnimationStateForURL(imageURL).timerActive}`);
done();
});
</script>

View file

@ -9,19 +9,21 @@
</style>
<div id="target"></div>
<script>
const imageURL = "../wpt-import/images/anim-gr.gif";
asyncTest(async done => {
await new Promise(resolve => window.addEventListener("load", resolve));
internals.dumpLayoutTree(document);
println(`active animations before replacement: ${internals.activeImageStyleValueAnimationCount()}`);
println(`timer active before replacement: ${internals.imageAnimationStateForURL(imageURL).timerActive}`);
target.style.display = "inline";
internals.dumpLayoutTree(document);
println(`active animations after replacement: ${internals.activeImageStyleValueAnimationCount()}`);
println(`timer active after replacement: ${internals.imageAnimationStateForURL(imageURL).timerActive}`);
target.style.display = "none";
internals.dumpLayoutTree(document);
println(`active animations after hiding: ${internals.activeImageStyleValueAnimationCount()}`);
println(`timer active after hiding: ${internals.imageAnimationStateForURL(imageURL).timerActive}`);
done();
});
</script>

View file

@ -9,16 +9,18 @@
</style>
<div id="target"></div>
<script>
const imageURL = "../wpt-import/images/anim-gr.gif";
asyncTest(async done => {
await new Promise(resolve => window.addEventListener("load", resolve));
internals.dumpLayoutTree(document);
println(`active animations before hiding: ${internals.activeImageStyleValueAnimationCount()}`);
println(`timer active before hiding: ${internals.imageAnimationStateForURL(imageURL).timerActive}`);
target.style.display = "none";
internals.dumpLayoutTree(document);
println(`active animations after hiding: ${internals.activeImageStyleValueAnimationCount()}`);
println(`timer active after hiding: ${internals.imageAnimationStateForURL(imageURL).timerActive}`);
done();
});
</script>

View file

@ -7,16 +7,18 @@
</style>
<div id="target"></div>
<script>
const imageURL = "../wpt-import/images/anim-gr.gif";
asyncTest(async done => {
await new Promise(resolve => window.addEventListener("load", resolve));
internals.dumpLayoutTree(document);
println(`active animations before hiding: ${internals.activeImageStyleValueAnimationCount()}`);
println(`timer active before hiding: ${internals.imageAnimationStateForURL(imageURL).timerActive}`);
target.style.display = "none";
internals.dumpLayoutTree(document);
println(`active animations after hiding: ${internals.activeImageStyleValueAnimationCount()}`);
println(`timer active after hiding: ${internals.imageAnimationStateForURL(imageURL).timerActive}`);
done();
});
</script>

View file

@ -0,0 +1,28 @@
<!doctype html>
<script src="../include.js"></script>
<img id="first" src="../../data/loop-count-2.gif" />
<img id="second" src="../../data/loop-count-2.gif" />
<script>
const imageURL = "../../data/loop-count-2.gif";
promiseTest(async () => {
const stateBeforeRestart = await waitForImageAnimationState(imageURL, state => state.frameIndex > 0);
println(`client count before restart: ${stateBeforeRestart.clientCount}`);
println(`frame advanced before restart: ${stateBeforeRestart.frameIndex > 0}`);
first.removeAttribute("src");
await timeout(0);
const reloaded = new Promise(resolve => first.addEventListener("load", resolve, { once: true }));
first.src = imageURL;
await reloaded;
const stateAfterRestart = internals.imageAnimationStateForURL(imageURL);
println(`session unchanged after restart: ${stateAfterRestart.sessionID === stateBeforeRestart.sessionID}`);
println(
`animation restarted after src reset: ${stateAfterRestart.frameIndex === 0 && stateAfterRestart.loopsCompleted === 0 && stateAfterRestart.timerActive}`
);
println(`client count after restart: ${stateAfterRestart.clientCount}`);
});
</script>

View file

@ -9,11 +9,13 @@
"
></div>
<script>
const imageURL = new URL("../wpt-import/images/anim-gr.gif", document.location).href;
asyncTest(done => {
window.addEventListener("load", () => {
internals.dumpLayoutTree(document);
const activeAnimationsBeforeBaseChange =
internals.activeImageStyleValueAnimationCount();
const timerActiveBeforeBaseChange =
internals.imageAnimationStateForURL(imageURL).timerActive;
const beforeBaseChange = getComputedStyle(target).backgroundImage;
const base = document.createElement("base");
@ -22,18 +24,18 @@
const afterBaseChange = getComputedStyle(target).backgroundImage;
internals.dumpLayoutTree(document);
const activeAnimationsAfterBaseChange =
internals.activeImageStyleValueAnimationCount();
const timerActiveAfterBaseChange =
internals.imageAnimationStateForURL(imageURL).timerActive;
println(
`Computed URL before base change: ${beforeBaseChange}`
);
println(`Computed URL after base change: ${afterBaseChange}`);
println(
`active animations before base change: ${activeAnimationsBeforeBaseChange}`
`timer active before base change: ${timerActiveBeforeBaseChange}`
);
println(
`active animations after base change: ${activeAnimationsAfterBaseChange}`
`timer active after base change: ${timerActiveAfterBaseChange}`
);
done();
});

View file

@ -52,6 +52,21 @@ function timeout(ms) {
return promise;
}
async function waitForImageAnimationState(url, predicate, targetWindow = window) {
return new Promise(async resolve => {
while (true) {
try {
const state = targetWindow.internals.imageAnimationStateForURL(url);
if (predicate(state)) return resolve(state);
} catch {
// The image hasn't loaded yet.
}
await animationFrame();
}
});
}
const __testErrorHandlerController = new AbortController();
window.addEventListener(
"error",