diff --git a/docs/validation/bounded-web-animations-677-242.md b/docs/validation/bounded-web-animations-677-242.md new file mode 100644 index 00000000..155eda29 --- /dev/null +++ b/docs/validation/bounded-web-animations-677-242.md @@ -0,0 +1,38 @@ +# Bounded Web Animations API + +Issue #677 adds a product-neutral Web Animations layer over WebScene's retained +host-clock animation tracks. `Element.animate()`, `Animation`, +`KeyframeEffect`, element/document `getAnimations()`, `currentTime`, +`play()`, `pause()`, `cancel()`, `finish()`, `ready` and `finished` are exposed +without an Electron or application shim. + +The first slice accepts two to sixteen ordered keyframes and compiles opacity +and the existing bounded two-dimensional translate, scale and rotation grammar +once. Duration and absolute delay are capped at one hour, finite iteration +count at one million, and the combined CSS/WAAPI track count at eight per +element, with a 1,024-animation document registry ceiling. Easing is limited +to the five supported keyword curves. Property +indexed keyframes, partial-property interpolation, filters, additive +composition, playback-rate mutation and arbitrary timelines remain explicit +later scope. + +WAAPI tracks sample the same monotonic host timestamp as CSS animations and +replace-compose after CSS tracks in creation order. Paused, canceled, finished +and statically filled tracks do not request frames. Cancellation rejects +`finished` with `AbortError`; natural or explicit completion resolves it and +dispatches one finish event. Track removal, subtree detach and document reset +clear native ownership and stale paint. CSS mutations that consume the shared +eight-track budget cancel newest excess WAAPI tracks. + +`new Animation(effect)` remains idle with a null timeline. Passing the owning +document timeline admits the bounded effect, while `Element.animate()` selects +that timeline and starts it. Negative finite `currentTime` values round-trip; +seek or replay discards a stale queued finish notification and renews a settled +`finished` promise for the new playback generation. The runtime `EventTarget` +owns `onfinish` and `oncancel` delivery so handlers and listener callbacks fire +once. + +The native regression covers identity, midpoint paint, seek, pause/resume, +finish, fill, detach cancellation and frame demand. The browser contract uses +the same public API shape. Only `git diff --check` was run under the directed +fast policy; compilation and runtime execution remain for CI. diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h index c102491e..e5f3d29d 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h @@ -1535,6 +1535,12 @@ struct dom_node final { bool transform_override{false}; bool filter_override{false}; }; + struct web_animation final { + uint32_t id{0}; + node_style::animation_data::keyframe_animation effect; + keyframe_animation_runtime runtime; + bool finished{false}; + }; css_length painted_transform_translate_x{}; css_length painted_transform_translate_y{}; css_length transform_animation_from_translate_x{}; @@ -1600,6 +1606,7 @@ struct dom_node final { bool opacity_animation_active{false}; bool opacity_animation_start_event_sent{false}; std::vector keyframe_animations; + std::vector web_animations; pseudo_animation_runtime before_pseudo; pseudo_animation_runtime after_pseudo; bool keyframe_opacity_override{false}; @@ -2071,6 +2078,26 @@ class native_document final { std::string pseudo_element; }; + struct web_animation_event_record final { + uint32_t node_id{0}; + uint32_t animation_id{0}; + bool canceled{false}; + }; + + enum class web_animation_command : uint8_t { + play, + pause, + cancel, + finish, + seek + }; + + struct web_animation_state final { + double current_time_ms{0}; + bool paused{false}; + bool finished{false}; + }; + explicit native_document( webscene_text_measure_callback text_measure_callback = nullptr, void* text_measure_user_data = nullptr); @@ -2177,6 +2204,18 @@ class native_document final { bool has_active_animations() const noexcept; std::vector take_transition_events(); std::vector take_animation_events(); + bool create_web_animation( + dom_node& node, + uint32_t id, + node_style::animation_data::keyframe_animation effect); + bool control_web_animation( + uint32_t id, + web_animation_command command, + double seek_time_ms = 0); + std::optional web_animation_status(uint32_t id) const; + std::vector web_animation_ids(const dom_node& node) const; + void cancel_web_animations(dom_node& root, bool include_descendants); + std::vector take_web_animation_events(); float measure_inline_content_width(const dom_node& node) const; size_t text_caret_offset_at_x(const dom_node& node, float x) const; webscene_text_metrics measure_text( @@ -2575,6 +2614,11 @@ class native_document final { // vector here would add its full implementation-specific footprint to // every document, including documents that never run an animation. std::unique_ptr> animation_events_; + std::unique_ptr> + web_animation_events_; + static constexpr size_t maximum_live_web_animations = 1024U; + std::unique_ptr> + web_animation_nodes_; webscene_text_measure_callback text_measure_callback_{nullptr}; void* text_measure_user_data_{nullptr}; mutable std::unordered_map< diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_animations.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_animations.inc index d9f08554..a5b47d6b 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_animations.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_animations.inc @@ -71,6 +71,17 @@ bool fills_after(node_style::animation_data::fill_kind fill) noexcept return fill == node_style::animation_data::fill_kind::forwards || fill == node_style::animation_data::fill_kind::both; } + +void discard_web_animation_events( + std::unique_ptr>& events, + uint32_t animation_id) +{ + if (!events) return; + std::erase_if(*events, [animation_id](const auto& event) { + return event.animation_id == animation_id; + }); + if (events->empty()) events.reset(); +} } // namespace void native_document::signal_animation_frame(double timestamp_ms) noexcept @@ -80,6 +91,213 @@ void native_document::signal_animation_frame(double timestamp_ms) noexcept } } +bool native_document::create_web_animation( + dom_node& node, + uint32_t id, + node_style::animation_data::keyframe_animation effect) +{ + auto& animation = node.mutable_animation_runtime(); + const auto css_track_count = std::count_if( + node.style.animations().keyframe_animations.begin(), + node.style.animations().keyframe_animations.end(), + [](const auto& track) { return !track.signature.empty(); }); + if (id == 0U + || (web_animation_nodes_ + && web_animation_nodes_->size() >= maximum_live_web_animations) + || (web_animation_nodes_ && web_animation_nodes_->contains(id)) + || css_track_count + animation.web_animations.size() + >= node_style::animation_data::max_keyframe_animation_tracks + || std::any_of( + animation.web_animations.begin(), animation.web_animations.end(), + [id](const auto& candidate) { return candidate.id == id; })) { + return false; + } + + dom_node::animation_runtime_data::web_animation record; + record.id = id; + record.effect = std::move(effect); + record.effect.name.clear(); + record.effect.signature = "waapi:" + std::to_string(id); + record.runtime.name.clear(); + record.runtime.signature = record.effect.signature; + record.runtime.started_ms = animation_frame_timestamp_ms_; + record.runtime.fill_mode = record.effect.fill_mode; + record.runtime.active = true; + record.runtime.painted_opacity = node.style.opacity; + record.runtime.painted_translation_x = node.style.transform_translate_x; + record.runtime.painted_translation_y = node.style.transform_translate_y; + record.runtime.painted_scale_x = node.style.transform_scale_x; + record.runtime.painted_scale_y = node.style.transform_scale_y; + record.runtime.painted_rotation_degrees = node.style.transform_rotate_degrees; + animation.web_animations.push_back(std::move(record)); + if (!web_animation_nodes_) { + web_animation_nodes_ = + std::make_unique>(); + } + web_animation_nodes_->emplace(id, node.id); + active_animation_demand_cache_valid_ = false; + last_animation_advance_timestamp_ms_ = + std::numeric_limits::quiet_NaN(); + static_cast(advance_animations()); + mark_dirty(); + return true; +} + +bool native_document::control_web_animation( + uint32_t id, + web_animation_command command, + double seek_time_ms) +{ + if (!web_animation_nodes_) return false; + const auto owner = web_animation_nodes_->find(id); + if (owner == web_animation_nodes_->end()) return false; + auto* node = find_by_native_id(owner->second); + if (node == nullptr || node->animation_runtime() == nullptr) return false; + auto* animation = node->animation_runtime(); + auto found = std::find_if( + animation->web_animations.begin(), animation->web_animations.end(), + [id](const auto& candidate) { return candidate.id == id; }); + if (found == animation->web_animations.end()) return false; + auto& record = *found; + auto& runtime = record.runtime; + switch (command) { + case web_animation_command::play: + if (record.finished) { + discard_web_animation_events(web_animation_events_, id); + runtime.started_ms = animation_frame_timestamp_ms_; + runtime.active = true; + runtime.filled = false; + runtime.end_event_sent = false; + record.finished = false; + } else if (runtime.paused) { + runtime.started_ms += std::max( + 0.0, animation_frame_timestamp_ms_ - runtime.paused_at_ms); + } + runtime.paused = false; + break; + case web_animation_command::pause: + if (!runtime.paused && !record.finished) { + runtime.paused = true; + runtime.paused_at_ms = animation_frame_timestamp_ms_; + } + break; + case web_animation_command::cancel: + discard_web_animation_events(web_animation_events_, id); + if (!web_animation_events_) { + web_animation_events_ = + std::make_unique>(); + } + web_animation_events_->push_back({node->id, id, true}); + animation->web_animations.erase(found); + web_animation_nodes_->erase(id); + break; + case web_animation_command::finish: { + const auto active_duration = keyframe_active_duration_ms(record.effect); + if (!std::isfinite(active_duration)) return false; + runtime.started_ms = animation_frame_timestamp_ms_ + - record.effect.delay_ms - active_duration; + runtime.paused = false; + runtime.active = true; + record.finished = false; + break; + } + case web_animation_command::seek: + if (!std::isfinite(seek_time_ms)) return false; + discard_web_animation_events(web_animation_events_, id); + runtime.started_ms = (runtime.paused + ? runtime.paused_at_ms : animation_frame_timestamp_ms_) + - seek_time_ms; + runtime.active = true; + runtime.filled = false; + runtime.end_event_sent = false; + record.finished = false; + break; + } + active_animation_demand_cache_valid_ = false; + last_animation_advance_timestamp_ms_ = + std::numeric_limits::quiet_NaN(); + static_cast(advance_animations()); + mark_dirty(); + return true; +} + +std::optional +native_document::web_animation_status(uint32_t id) const +{ + if (!web_animation_nodes_) return std::nullopt; + const auto owner = web_animation_nodes_->find(id); + if (owner == web_animation_nodes_->end() + || owner->second == 0U || owner->second > native_id_index_.size()) { + return std::nullopt; + } + const auto* node = native_id_index_[owner->second - 1U]; + if (node == nullptr || node->animation_runtime() == nullptr) { + return std::nullopt; + } + const auto* animation = node->animation_runtime(); + const auto found = std::find_if( + animation->web_animations.begin(), animation->web_animations.end(), + [id](const auto& candidate) { return candidate.id == id; }); + if (found == animation->web_animations.end()) return std::nullopt; + const auto timestamp = found->runtime.paused + ? found->runtime.paused_at_ms : animation_frame_timestamp_ms_; + const auto current_time = found->finished + ? static_cast(found->effect.delay_ms) + + keyframe_active_duration_ms(found->effect) + : timestamp - found->runtime.started_ms; + return web_animation_state{ + current_time, + found->runtime.paused, + found->finished}; +} + +std::vector native_document::web_animation_ids( + const dom_node& node) const +{ + std::vector result; + const auto* animation = node.animation_runtime(); + if (animation == nullptr) return result; + result.reserve(animation->web_animations.size()); + for (const auto& record : animation->web_animations) result.push_back(record.id); + return result; +} + +void native_document::cancel_web_animations( + dom_node& root, + bool include_descendants) +{ + auto canceled = false; + const auto cancel_node = [&](dom_node& node) { + auto* animation = node.animation_runtime(); + if (animation == nullptr || animation->web_animations.empty()) return; + for (const auto& record : animation->web_animations) { + discard_web_animation_events(web_animation_events_, record.id); + if (!web_animation_events_) { + web_animation_events_ = std::make_unique< + std::vector>(); + } + web_animation_events_->push_back({node.id, record.id, true}); + if (web_animation_nodes_) web_animation_nodes_->erase(record.id); + } + animation->web_animations.clear(); + canceled = true; + }; + const auto visit = [&](const auto& self, dom_node& node) -> void { + cancel_node(node); + if (!include_descendants) return; + for (auto* child : node.children) { + if (child != nullptr) self(self, *child); + } + }; + visit(visit, root); + if (!canceled) return; + active_animation_demand_cache_valid_ = false; + last_animation_advance_timestamp_ms_ = + std::numeric_limits::quiet_NaN(); + static_cast(advance_animations()); + mark_dirty(); +} + void native_document::update_style_animations(dom_node& node) { const auto has_pseudo_animation = node.style.has_pseudo_elements() @@ -277,6 +495,30 @@ void native_document::update_style_animations(dom_node& node) animation.top_animation_start_event_sent); const auto& configured_keyframes = node.style.animations().keyframe_animations; + const auto css_track_count = static_cast(std::count_if( + configured_keyframes.begin(), configured_keyframes.end(), + [](const auto& track) { return !track.signature.empty(); })); + const auto web_capacity = + node_style::animation_data::max_keyframe_animation_tracks + - std::min(css_track_count, + node_style::animation_data::max_keyframe_animation_tracks); + if (animation.web_animations.size() > web_capacity) { + for (size_t index = web_capacity; + index < animation.web_animations.size(); ++index) { + discard_web_animation_events( + web_animation_events_, animation.web_animations[index].id); + if (!web_animation_events_) { + web_animation_events_ = std::make_unique< + std::vector>(); + } + web_animation_events_->push_back( + {node.id, animation.web_animations[index].id, true}); + if (web_animation_nodes_) { + web_animation_nodes_->erase(animation.web_animations[index].id); + } + } + animation.web_animations.resize(web_capacity); + } auto keyframe_set_changed = animation.keyframe_animations.size() != configured_keyframes.size(); auto keyframe_phase_changed = keyframe_set_changed; @@ -1458,6 +1700,174 @@ bool native_document::advance_animations() noexcept runtime.end_event_sent = true; } } + // Script-created animations share the host timestamp and retained + // property sampler. They compose after CSS animations in creation + // order, matching replace composition without another scheduler. + for (auto& record : animation.web_animations) { + auto& runtime = record.runtime; + const auto& configured = record.effect; + auto contributes = false; + auto progress = 0.0F; + auto completed = false; + if (!runtime.active) { + contributes = runtime.filled; + } else { + const auto timestamp_ms = runtime.paused + ? runtime.paused_at_ms : animation_frame_timestamp_ms_; + const auto elapsed_ms = static_cast( + timestamp_ms - runtime.started_ms - configured.delay_ms); + if (elapsed_ms < 0) { + contributes = fills_before(configured.fill_mode); + if (contributes) { + progress = directed_keyframe_progress( + 0.0F, 0.0, configured.direction); + } + } else { + const auto active_duration = + keyframe_active_duration_ms(configured); + const auto instantaneous = active_duration == 0; + if (instantaneous || elapsed_ms >= active_duration) { + contributes = fills_after(configured.fill_mode); + if (contributes) { + progress = terminal_keyframe_progress( + configured.iterations, configured.direction); + } + if (!runtime.paused || instantaneous) { + runtime.active = false; + runtime.filled = contributes; + record.finished = true; + completed = true; + } + } else { + contributes = true; + runtime.filled = false; + progress = active_keyframe_progress( + elapsed_ms, configured.duration_ms, + configured.direction); + } + } + } + const auto eased_segment = [&](float segment_progress) { + return timing_value( + segment_progress, + configured.x1, configured.y1, + configured.x2, configured.y2, + configured.timing_kind, + configured.step_count, + configured.step_position); + }; + if (contributes && configured.opacity_keyframes.size() >= 2U) { + const auto& stops = configured.opacity_keyframes; + auto right = std::lower_bound( + stops.begin(), stops.end(), progress, + [](const auto& stop, float value) { + return stop.offset < value; + }); + if (right == stops.begin()) runtime.painted_opacity = right->opacity; + else if (right == stops.end()) { + runtime.painted_opacity = stops.back().opacity; + } else { + const auto& from = *(right - 1); + const auto local = std::clamp( + (progress - from.offset) + / std::max(0.0001F, right->offset - from.offset), + 0.0F, 1.0F); + const auto eased = eased_segment(local); + runtime.painted_opacity = from.opacity + + (right->opacity - from.opacity) * eased; + } + winning_opacity = runtime.painted_opacity; + animation.keyframe_opacity_override = true; + } + if (contributes && configured.translation_keyframes.size() >= 2U) { + const auto& stops = configured.translation_keyframes; + auto right = std::lower_bound( + stops.begin(), stops.end(), progress, + [](const auto& stop, float value) { + return stop.offset < value; + }); + if (right == stops.begin()) { + runtime.painted_translation_x = right->x; + runtime.painted_translation_y = right->y; + } else if (right == stops.end()) { + runtime.painted_translation_x = stops.back().x; + runtime.painted_translation_y = stops.back().y; + } else { + const auto& from = *(right - 1); + const auto eased = eased_segment(std::clamp( + (progress - from.offset) + / std::max(0.0001F, right->offset - from.offset), + 0.0F, 1.0F)); + runtime.painted_translation_x = interpolate_length( + from.x, right->x, eased); + runtime.painted_translation_y = interpolate_length( + from.y, right->y, eased); + } + winning_translation_x = runtime.painted_translation_x; + winning_translation_y = runtime.painted_translation_y; + animation.keyframe_translation_override = true; + } + if (contributes && configured.scale_keyframes.size() >= 2U) { + const auto& stops = configured.scale_keyframes; + auto right = std::lower_bound( + stops.begin(), stops.end(), progress, + [](const auto& stop, float value) { + return stop.offset < value; + }); + if (right == stops.begin()) { + runtime.painted_scale_x = right->x; + runtime.painted_scale_y = right->y; + } else if (right == stops.end()) { + runtime.painted_scale_x = stops.back().x; + runtime.painted_scale_y = stops.back().y; + } else { + const auto& from = *(right - 1); + const auto eased = eased_segment(std::clamp( + (progress - from.offset) + / std::max(0.0001F, right->offset - from.offset), + 0.0F, 1.0F)); + runtime.painted_scale_x = from.x + + (right->x - from.x) * eased; + runtime.painted_scale_y = from.y + + (right->y - from.y) * eased; + } + winning_scale_x = runtime.painted_scale_x; + winning_scale_y = runtime.painted_scale_y; + animation.keyframe_scale_override = true; + } + if (contributes && configured.rotation_keyframes.size() >= 2U) { + const auto& stops = configured.rotation_keyframes; + auto right = std::lower_bound( + stops.begin(), stops.end(), progress, + [](const auto& stop, float value) { + return stop.offset < value; + }); + if (right == stops.begin()) { + runtime.painted_rotation_degrees = right->degrees; + } else if (right == stops.end()) { + runtime.painted_rotation_degrees = stops.back().degrees; + } else { + const auto& from = *(right - 1); + const auto eased = eased_segment(std::clamp( + (progress - from.offset) + / std::max(0.0001F, right->offset - from.offset), + 0.0F, 1.0F)); + runtime.painted_rotation_degrees = from.degrees + + (right->degrees - from.degrees) * eased; + } + winning_rotation = runtime.painted_rotation_degrees; + animation.keyframe_rotation_override = true; + } + advanced = advanced || (contributes && keyframe_paint_visible); + if (completed && !runtime.end_event_sent) { + if (!web_animation_events_) { + web_animation_events_ = std::make_unique< + std::vector>(); + } + web_animation_events_->push_back({node.id, record.id, false}); + runtime.end_event_sent = true; + } + } if (animation.keyframe_opacity_override) { animation.painted_opacity = winning_opacity; } else if (previous_opacity_override) { @@ -1779,6 +2189,11 @@ bool native_document::has_active_animations() const noexcept animation->keyframe_animations.end(), [](const auto& track) { return track.active && !track.paused; }); + const auto web_animation_active = std::any_of( + animation->web_animations.begin(), + animation->web_animations.end(), [](const auto& track) { + return track.runtime.active && !track.runtime.paused; + }); const auto pseudo_active = [](const auto& pseudo) { return std::any_of( pseudo.keyframe_animations.begin(), @@ -1794,7 +2209,8 @@ bool native_document::has_active_animations() const noexcept && !node->style.after_pseudo().display_none && pseudo_active(animation->after_pseudo)); return transition_active - || ((keyframe_active || generated_pseudo_active) + || ((keyframe_active || web_animation_active + || generated_pseudo_active) && intersects_visible_paint_area(*node)); }); active_animation_demand_cache_valid_ = true; @@ -1817,3 +2233,12 @@ native_document::take_animation_events() animation_events_.reset(); return events; } + +std::vector +native_document::take_web_animation_events() +{ + if (!web_animation_events_) return {}; + auto events = std::move(*web_animation_events_); + web_animation_events_.reset(); + return events; +} diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc index d43bbd47..1e5fcfe4 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc @@ -158,7 +158,9 @@ native_document::allocation_metrics native_document::read_allocation_metrics() c + animation->filter_animation_target.capacity() * sizeof(retained_filter_function) + animation->keyframe_animations.capacity() - * sizeof(dom_node::animation_runtime_data::keyframe_animation_runtime); + * sizeof(dom_node::animation_runtime_data::keyframe_animation_runtime) + + animation->web_animations.capacity() + * sizeof(dom_node::animation_runtime_data::web_animation); for (const auto& track : animation->keyframe_animations) { result.animation_runtime_storage_bytes += track.name.capacity() + track.signature.capacity() + 2U @@ -173,6 +175,21 @@ native_document::allocation_metrics native_document::read_allocation_metrics() c * sizeof(retained_filter_function); } } + for (const auto& record : animation->web_animations) { + const auto& effect = record.effect; + const auto& track = record.runtime; + result.animation_runtime_storage_bytes += + effect.name.capacity() + effect.signature.capacity() + 2U + + effect.opacity_keyframes.capacity() + * sizeof(node_style::opacity_keyframe) + + effect.translation_keyframes.capacity() + * sizeof(node_style::translation_keyframe) + + effect.scale_keyframes.capacity() + * sizeof(node_style::scale_keyframe) + + effect.rotation_keyframes.capacity() + * sizeof(node_style::rotation_keyframe) + + track.name.capacity() + track.signature.capacity() + 2U; + } const auto count_pseudo_runtime = [&](const auto& pseudo) { result.animation_runtime_storage_bytes += pseudo.keyframe_animations.capacity() diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_tree.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_tree.inc index 268fa920..5aeeb663 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_tree.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_tree.inc @@ -628,6 +628,14 @@ size_t native_document::erase_detached_subtrees( }); if (animation_events_->empty()) animation_events_.reset(); } + if (web_animation_nodes_) { + std::erase_if(*web_animation_nodes_, [&](const auto& entry) { + return entry.second == 0U + || entry.second > native_id_index_.size() + || native_id_index_[entry.second - 1U] == nullptr; + }); + if (web_animation_nodes_->empty()) web_animation_nodes_.reset(); + } if (shadow_dom_storage_ != nullptr) { for (const auto* node : removed) { shadow_dom_storage_->node_data.erase(node->id); @@ -982,6 +990,8 @@ void native_document::clear() node_pool_.release(); transition_events_.clear(); animation_events_.reset(); + web_animation_events_.reset(); + web_animation_nodes_.reset(); last_animation_advance_timestamp_ms_ = std::numeric_limits::quiet_NaN(); next_node_id_ = 1; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp index c21903c3..c5c817eb 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp @@ -27,6 +27,7 @@ #include "webscene_profile_storage.h" #include "webscene_indexeddb_compatibility.h" #include "webscene_stylesheet_cssom_compatibility.h" +#include "webscene_web_animations_compatibility.h" #include "webscene_secure_random.h" #include "webscene_crypto_provider.h" @@ -5018,6 +5019,7 @@ struct v8_dom_runtime::implementation final { install_fetch_globals(local_context); install_intersection_observer_polyfill(local_context); install_web_api_compatibility(local_context); + install_web_animations_api(local_context); #if defined(WEBSCENE_NATIVE_ENGINE_ENABLE_MEDIA) install_media_globals(local_context); #endif @@ -7181,6 +7183,7 @@ struct v8_dom_runtime::implementation final { #include "webscene_v8_runtime_tasks.inc" #include "webscene_v8_runtime_resources.inc" #include "webscene_v8_runtime_dom_core.inc" +#include "webscene_v8_runtime_web_animations.inc" #include "webscene_v8_runtime_diagnostics.inc" #include "webscene_v8_runtime_dom_properties.inc" #include "webscene_v8_runtime_canvas.inc" diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc index 878e7839..05530e24 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc @@ -1782,7 +1782,9 @@ { auto events = document.take_transition_events(); auto animation_events = document.take_animation_events(); - if (events.empty() && animation_events.empty()) return true; + auto web_animation_events = document.take_web_animation_events(); + if (events.empty() && animation_events.empty() + && web_animation_events.empty()) return true; auto isolate_locker = lock_shared_isolate(); v8::Isolate::Scope isolate_scope(isolate); v8::HandleScope handle_scope(isolate); @@ -1852,6 +1854,32 @@ return false; } } + for (const auto& animation : web_animation_events) { + auto* node = document.find_by_native_id(animation.node_id); + if (node == nullptr) continue; + auto event_context = context_for_node(*node); + if (event_context.IsEmpty()) continue; + v8::Context::Scope event_context_scope(event_context); + auto global = event_context->Global(); + v8::Local settle; + if (!global->Get( + event_context, + js_string(isolate, "__webSceneSettleWebAnimation")) + .ToLocal(&settle) + || !settle->IsFunction()) { + continue; + } + v8::Local arguments[]{ + v8::Integer::NewFromUnsigned(isolate, animation.animation_id), + js_string(isolate, animation.canceled ? "cancel" : "finish")}; + if (settle.As()->Call( + event_context, global, 2, arguments).IsEmpty()) { + last_error = "Web Animation settlement failed: " + + describe_reported_exception(try_catch, event_context); + ++frame_script_error_count; + return false; + } + } perform_microtask_checkpoint(); return true; } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc index 88cef060..75d0db20 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc @@ -1832,6 +1832,7 @@ void register_detached_subtree(dom_node& root) { if (&root == &document.body() || root.parent != nullptr) return; + document.cancel_web_animations(root, true); // Detached leaves are the common text/topology case. A leaf cannot // contain an existing root and, after unlinking, cannot still be // contained by one. Index it directly so repeated split/normalize diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc index 18174840..a56e91ed 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc @@ -403,6 +403,7 @@ custom_element_reaction_scope* active_custom_element_reaction_scope{nullptr}; std::vector loaded_resource_names; uint32_t next_timer_id{1}; + uint32_t next_web_animation_id{1}; uint32_t active_timer_id{0}; bool active_timer_cancelled{false}; bool prefer_timer_task{true}; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_web_animations.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_web_animations.inc new file mode 100644 index 00000000..98beaba2 --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_web_animations.inc @@ -0,0 +1,112 @@ + static bool web_animation_property(v8::Local context, + v8::Local object,const char* name,v8::Local& value) + { return object->Get(context,js_string(context->GetIsolate(),name)).ToLocal(&value); } + + static void create_web_animation(const v8::FunctionCallbackInfo& info) + { + auto* self=current(info.GetIsolate()); + auto context=info.GetIsolate()->GetCurrentContext(); + if(info.Length()!=8||!info[0]->IsObject()||!info[1]->IsArray())return; + auto target=info[0].As(); + if(target->InternalFieldCount()<1)return; + auto* node=static_cast(target->GetAlignedPointerFromInternalField(0,v8::kEmbedderDataTypeTagDefault)); + if(node==nullptr||node->kind!=dom_node_kind::element||!self->is_connected(*node))return; + auto frames=info[1].As(); + if(frames->Length()<2U||frames->Length()>16U)return; + node_style::animation_data::keyframe_animation effect; + effect.duration_ms=static_cast(info[2]->NumberValue(context).FromMaybe(-1)); + effect.delay_ms=static_cast(info[3]->NumberValue(context).FromMaybe(0)); + effect.iterations=static_cast(info[4]->NumberValue(context).FromMaybe(-1)); + const auto direction=info[5]->Uint32Value(context).FromMaybe(4U); + const auto fill=info[6]->Uint32Value(context).FromMaybe(4U); + if(!std::isfinite(effect.duration_ms)||effect.duration_ms<0||effect.duration_ms>3'600'000.0F + ||!std::isfinite(effect.delay_ms)||std::abs(effect.delay_ms)>3'600'000.0F + ||effect.iterations<0||(std::isfinite(effect.iterations)&&effect.iterations>1'000'000.0F) + ||direction>3U||fill>3U)return; + effect.direction=static_cast(direction); + effect.fill_mode=static_cast(fill); + node_style::transition_timing timing; + css::parse_transition_timing(to_utf8(info.GetIsolate(),info[7]),timing); + effect.x1=timing.x1;effect.y1=timing.y1;effect.x2=timing.x2;effect.y2=timing.y2; + effect.step_count=timing.step_count;effect.timing_kind=timing.kind;effect.step_position=timing.steps_position; + bool opacity=false,transform=false; + for(uint32_t index=0;indexLength();++index){ + v8::Local raw; + if(!frames->Get(context,index).ToLocal(&raw)||!raw->IsObject())return; + auto frame=raw.As(); + v8::Local offset_value,opacity_value,transform_value; + if(!web_animation_property(context,frame,"offset",offset_value)||!offset_value->IsNumber() + ||!web_animation_property(context,frame,"opacity",opacity_value) + ||!web_animation_property(context,frame,"transform",transform_value))return; + const auto offset=static_cast(offset_value->NumberValue(context).FromMaybe(-1)); + if(!std::isfinite(offset)||offset<0||offset>1)return; + if(!opacity_value->IsNullOrUndefined()){ + const auto value=static_cast(opacity_value->NumberValue(context).FromMaybe( + std::numeric_limits::quiet_NaN())); + if(!std::isfinite(value))return; + effect.opacity_keyframes.push_back({offset,std::clamp(value,0.0F,1.0F)});opacity=true; + } + if(!transform_value->IsNullOrUndefined()){ + const auto parsed=css::parse_keyframe_transform(to_utf8(info.GetIsolate(),transform_value)); + if(!parsed)return; + effect.translation_keyframes.push_back({offset,parsed->translate_x,parsed->translate_y}); + effect.scale_keyframes.push_back({offset,parsed->scale_x,parsed->scale_y}); + effect.rotation_keyframes.push_back({offset,parsed->rotate_degrees});transform=true; + } + } + if((opacity&&effect.opacity_keyframes.size()!=frames->Length()) + ||(transform&&effect.translation_keyframes.size()!=frames->Length())||(!opacity&&!transform))return; + auto id=self->next_web_animation_id++; + if(id==0U){id=self->next_web_animation_id++;} + if(self->document.create_web_animation(*node,id,std::move(effect))) + info.GetReturnValue().Set(v8::Integer::NewFromUnsigned(info.GetIsolate(),id)); + } + + static void control_web_animation(const v8::FunctionCallbackInfo& info) + { + if(info.Length()<2||!info[0]->IsUint32()||!info[1]->IsUint32())return; + auto context=info.GetIsolate()->GetCurrentContext(); + const auto command=info[1]->Uint32Value(context).FromMaybe(5U); + if(command>4U)return; + const auto seek=info.Length()>2?info[2]->NumberValue(context).FromMaybe(0):0; + info.GetReturnValue().Set(v8::Boolean::New(info.GetIsolate(),current(info.GetIsolate())->document.control_web_animation( + info[0].As()->Value(),static_cast(command),seek))); + } + + static void web_animation_status(const v8::FunctionCallbackInfo& info) + { + if(info.Length()!=1||!info[0]->IsUint32())return; + const auto state=current(info.GetIsolate())->document.web_animation_status(info[0].As()->Value()); + if(!state)return; + auto context=info.GetIsolate()->GetCurrentContext();auto result=v8::Object::New(info.GetIsolate()); + result->Set(context,js_string(info.GetIsolate(),"currentTime"),v8::Number::New(info.GetIsolate(),state->current_time_ms)).Check(); + result->Set(context,js_string(info.GetIsolate(),"paused"),v8::Boolean::New(info.GetIsolate(),state->paused)).Check(); + result->Set(context,js_string(info.GetIsolate(),"finished"),v8::Boolean::New(info.GetIsolate(),state->finished)).Check(); + info.GetReturnValue().Set(result); + } + + static void element_web_animation_ids(const v8::FunctionCallbackInfo& info) + { + if(info.Length()!=1||!info[0]->IsObject())return; + auto target=info[0].As();if(target->InternalFieldCount()<1)return; + auto* node=static_cast(target->GetAlignedPointerFromInternalField(0,v8::kEmbedderDataTypeTagDefault)); + if(node==nullptr)return; + const auto ids=current(info.GetIsolate())->document.web_animation_ids(*node); + auto result=v8::Array::New(info.GetIsolate(),static_cast(ids.size())); + auto context=info.GetIsolate()->GetCurrentContext(); + for(uint32_t index=0;indexSet(context,index,v8::Integer::NewFromUnsigned(info.GetIsolate(),ids[index])).Check(); + info.GetReturnValue().Set(result); + } + + void install_web_animations_api(v8::Local local_context) + { + auto global=local_context->Global(); + const auto install=[&](const char* name,v8::FunctionCallback callback){ + global->Set(local_context,js_string(isolate,name),v8::Function::New(local_context,callback).ToLocalChecked()).Check();}; + install("__webSceneCreateWebAnimation",create_web_animation); + install("__webSceneControlWebAnimation",control_web_animation); + install("__webSceneWebAnimationStatus",web_animation_status); + install("__webSceneElementWebAnimationIds",element_web_animation_ids); + auto script=v8::Script::Compile(local_context,js_string(isolate,webAnimationsCompatibilityScript.data())).ToLocalChecked(); + script->Run(local_context).ToLocalChecked(); + } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_web_animations_compatibility.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_web_animations_compatibility.h new file mode 100644 index 00000000..c5759607 --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_web_animations_compatibility.h @@ -0,0 +1,126 @@ +#pragma once +#include + +namespace webscene_native { +inline constexpr std::string_view webAnimationsCompatibilityScript=R"JS( +(() => { + const createNative=globalThis.__webSceneCreateWebAnimation; + const controlNative=globalThis.__webSceneControlWebAnimation; + const statusNative=globalThis.__webSceneWebAnimationStatus; + const idsNative=globalThis.__webSceneElementWebAnimationIds; + delete globalThis.__webSceneCreateWebAnimation; + delete globalThis.__webSceneControlWebAnimation; + delete globalThis.__webSceneWebAnimationStatus; + delete globalThis.__webSceneElementWebAnimationIds; + const animations=new Map(); + const directions=new Map([['normal',0],['reverse',1],['alternate',2],['alternate-reverse',3]]); + const fills=new Map([['none',0],['forwards',1],['backwards',2],['both',3]]); + const easings=new Set(['linear','ease','ease-in','ease-out','ease-in-out']); + const deferred=()=>{let resolve,reject;const promise=new Promise((yes,no)=>{resolve=yes;reject=no;});return{promise,resolve,reject};}; + const normalizeTiming=options=>{ + if(typeof options==='number')options={duration:options}; + options=options==null?{}:Object(options); + const timing={duration:Number(options.duration??0),delay:Number(options.delay??0), + iterations:Number(options.iterations??1),direction:String(options.direction??'normal'), + fill:String(options.fill??'none'),easing:String(options.easing??'linear')}; + if((options.iterationStart!=null&&Number(options.iterationStart)!==0) + ||(options.endDelay!=null&&Number(options.endDelay)!==0) + ||(options.composite!=null&&String(options.composite)!=='replace')) + throw new TypeError('Unsupported bounded animation timing member'); + if(!Number.isFinite(timing.duration)||timing.duration<0||timing.duration>3600000 + ||!Number.isFinite(timing.delay)||Math.abs(timing.delay)>3600000 + ||(!(Number.isFinite(timing.iterations)||timing.iterations===Infinity)) + ||timing.iterations<0||timing.iterations>1000000||!directions.has(timing.direction) + ||!fills.has(timing.fill)||!easings.has(timing.easing)) + throw new TypeError('Unsupported bounded animation timing'); + return timing; + }; + const normalizeKeyframes=input=>{ + if(!Array.isArray(input)||input.length<2||input.length>16) + throw new TypeError('Keyframes must be an array of 2 to 16 entries'); + const frames=input.map(value=>{const frame=Object(value); + for(const name of Object.keys(frame))if(!['offset','opacity','transform'].includes(name)) + throw new TypeError(`Unsupported bounded keyframe property: ${name}`); + const result={ + offset:frame.offset==null?null:Number(frame.offset), + opacity:frame.opacity==null?null:Number(frame.opacity), + transform:frame.transform==null?null:String(frame.transform)}; + if(result.offset!==null&&(!Number.isFinite(result.offset)||result.offset<0||result.offset>1) + ||result.opacity!==null&&!Number.isFinite(result.opacity)) + throw new TypeError('Invalid bounded animation keyframe');return result;}); + const explicit=frames.every(frame=>frame.offset!==null),implicit=frames.every(frame=>frame.offset===null); + if(!explicit&&!implicit)throw new TypeError('Offsets must be all explicit or all implicit'); + if(implicit)frames.forEach((frame,index)=>{frame.offset=index/(frames.length-1);}); + for(let index=1;indexframe.opacity!==null),transform=frames.every(frame=>frame.transform!==null); + if((!opacity&&frames.some(frame=>frame.opacity!==null)) + ||(!transform&&frames.some(frame=>frame.transform!==null))||(!opacity&&!transform)) + throw new TypeError('Each supported property requires every keyframe'); + return frames; + }; + class KeyframeEffect{ + constructor(target,keyframes,options={}){ + if(!(target instanceof Element))throw new TypeError('KeyframeEffect target must be an Element'); + this.target=target;this._keyframes=normalizeKeyframes(keyframes); + this._timing=normalizeTiming(options);this._animation=null; + } + getKeyframes(){return this._keyframes.map(frame=>({...frame}));} + getTiming(){return{...this._timing};} + } + const nativeCreate=animation=>{const timing=animation.effect._timing; + const id=createNative(animation.effect.target,animation.effect._keyframes, + timing.duration,timing.delay,timing.iterations,directions.get(timing.direction), + fills.get(timing.fill),timing.easing);if(!id)return false;animation._id=id;return true;}; + const renew=animation=>{animation._settled=false;animation._deferred=deferred(); + animation.finished=animation._deferred.promise;}; + class Animation extends EventTarget{ + constructor(effect,animationTimeline=null){super();if(!(effect instanceof KeyframeEffect)||effect._animation) + throw new TypeError('Animation requires an unused KeyframeEffect'); + if(animationTimeline!==null&&animationTimeline!==document.timeline) + throw new DOMException('Only the document timeline is supported','NotSupportedError'); + this.effect=effect;effect._animation=this;this.onfinish=null;this.oncancel=null; + this._id=0;this._settled=false;this._deferred=deferred(); + this.finished=this._deferred.promise;this.ready=Promise.resolve(this);this.timeline=animationTimeline; + } + get currentTime(){return statusNative(this._id)?.currentTime??null;} + set currentTime(value){value=Number(value);if(!Number.isFinite(value)) + throw new TypeError('currentTime must be finite'); + const settled=this._settled;if(!controlNative(this._id,4,value)) + throw new DOMException('Animation is idle','InvalidStateError'); + if(settled)renew(this);} + get playState(){const state=statusNative(this._id);return!state?'idle':state.finished?'finished':state.paused?'paused':'running';} + play(){if(this.timeline!==document.timeline)throw new DOMException( + 'A document timeline is required','NotSupportedError'); + if(!statusNative(this._id)){if(this._id&&animations.get(this._id)===this&&!this._settled) + settle(this._id,'cancel'); + animations.delete(this._id);if(this._settled)renew(this); + if(!nativeCreate(this))throw new DOMException('Animation capacity unavailable','NotSupportedError'); + animations.set(this._id,this);return this.ready;} + if(this._settled)renew(this);controlNative(this._id,0);return this.ready;} + pause(){controlNative(this._id,1);} + cancel(){if(controlNative(this._id,2))settle(this._id,'cancel');} + finish(){if(!controlNative(this._id,3))throw new DOMException( + 'Infinite animations cannot finish','InvalidStateError');} + } + const settle=(id,kind)=>{const animation=animations.get(id);if(!animation)return; + if(animation._settled){if(kind==='cancel')animations.delete(id);return;} + animation._settled=true;const event=new Event(kind); + if(kind==='finish'){animation._deferred.resolve(animation);animation.dispatchEvent(event);} + else{animation._deferred.reject(new DOMException('The animation was canceled','AbortError')); + animation.dispatchEvent(event);animations.delete(id);}}; + Object.defineProperty(globalThis,'__webSceneSettleWebAnimation',{value:settle,configurable:true}); + Object.defineProperty(Element.prototype,'animate',{value(keyframes,options){ + const animation=new Animation(new KeyframeEffect(this,keyframes,options),document.timeline); + animation.play();return animation;},configurable:true,writable:true}); + Object.defineProperty(Element.prototype,'getAnimations',{value(){return Array.from( + idsNative(this),id=>animations.get(id)).filter(Boolean);},configurable:true,writable:true}); + Object.defineProperty(Document.prototype,'getAnimations',{value(){return Array.from( + animations.values()).filter(animation=>animation.playState!=='idle');},configurable:true,writable:true}); + const timeline=Object.freeze({get currentTime(){return performance.now();}}); + Object.defineProperty(Document.prototype,'timeline',{get(){return timeline;},configurable:true}); + Object.defineProperties(globalThis,{Animation:{value:Animation,configurable:true,writable:true}, + KeyframeEffect:{value:KeyframeEffect,configurable:true,writable:true}}); +})(); +)JS"; +} diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_animation_cssom_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_animation_cssom_tests.inc index efc07158..3ad88f42 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_animation_cssom_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_animation_cssom_tests.inc @@ -1196,6 +1196,94 @@ void test_zero_time_animation_tracks_settle_without_frame_demand( engine, false, "detached zero-time animations retained frame demand"); } +void test_bounded_web_animations_share_host_clock_and_lifecycle( + webscene_engine* engine) +{ + animation_frame_and_wait(engine, 18000.0, 7170U); + execute_and_wait(engine, R"JS( + (() => { + document.body.innerHTML = '
'; + const node = document.getElementById('waapi'); + globalThis.__waapiEvents = []; + globalThis.__waapi = node.animate([ + { opacity: 0, transform: 'translateX(0) scale(1) rotate(0)' }, + { opacity: 1, transform: 'translateX(40px) scale(2) rotate(180deg)' } + ], { duration: 1000, fill: 'both', easing: 'linear' }); + __waapi.onfinish = () => __waapiEvents.push('finish'); + __waapi.oncancel = () => __waapiEvents.push('cancel'); + globalThis.__waapiIdle = new Animation(new KeyframeEffect(node, + [{ opacity: 0 }, { opacity: 1 }], { duration: 1000 })); + })() + )JS", "bounded-waapi-setup.js"); + wait_for_animation_frame_demand( + engine, true, "running WAAPI track lost host-frame demand"); + animation_frame_and_wait(engine, 18500.0, 7171U); + const auto midpoint = evaluate(engine, R"JS( + (() => { + const style = getComputedStyle(document.getElementById('waapi')); + return __waapi instanceof Animation + && __waapi.effect instanceof KeyframeEffect + && document.getElementById('waapi').getAnimations()[0] === __waapi + && document.getAnimations()[0] === __waapi + && __waapi.timeline === document.timeline + && __waapiIdle.timeline === null + && __waapiIdle.playState === 'idle' + && __waapiIdle.currentTime === null + && !document.getAnimations().includes(__waapiIdle) + && __waapi.playState === 'running' + && Math.abs(__waapi.currentTime - 500) < .01 + && Math.abs(Number(style.opacity) - .5) < .01; + })() + )JS", "bounded-waapi-midpoint.js"); + require(midpoint == "true", + "bounded WAAPI identity, clock, or retained midpoint was incorrect"); + + execute_and_wait(engine, "__waapi.pause()", "bounded-waapi-pause.js"); + wait_for_animation_frame_demand( + engine, false, "paused WAAPI track retained host-frame demand"); + animation_frame_and_wait(engine, 19000.0, 7172U); + require(evaluate(engine, + "Math.abs(__waapi.currentTime - 500) < .01 && __waapi.playState === 'paused'", + "bounded-waapi-held.js") == "true", + "paused WAAPI track did not hold host time"); + execute_and_wait(engine, "__waapi.currentTime = -25", + "bounded-waapi-negative-seek.js"); + require(evaluate(engine, + "Math.abs(__waapi.currentTime + 25) < .01 && __waapi.playState === 'paused'", + "bounded-waapi-negative-current-time.js") == "true", + "negative WAAPI currentTime did not round-trip while paused"); + execute_and_wait(engine, "__waapi.currentTime = 750; __waapi.play()", + "bounded-waapi-seek-resume.js"); + animation_frame_and_wait(engine, 19250.0, 7173U); + require(evaluate(engine, R"JS( + __waapi.playState === 'finished' && __waapiEvents.join(',') === 'finish' + && Math.abs(Number(getComputedStyle( + document.getElementById('waapi')).opacity) - 1) < .001 + )JS", "bounded-waapi-finished.js") == "true", + "WAAPI finish did not settle once with forwards fill"); + wait_for_animation_frame_demand( + engine, false, "filled WAAPI track retained host-frame demand"); + + execute_and_wait(engine, R"JS( + (() => { + const node = document.getElementById('waapi'); + globalThis.__canceled = node.animate( + [{ opacity: 1 }, { opacity: 0 }], 1000); + __canceled.oncancel = () => __waapiEvents.push('cancel'); + __canceled.finished.catch(error => __waapiEvents.push(error.name)); + node.remove(); + })() + )JS", "bounded-waapi-detach.js"); + animation_frame_and_wait(engine, 19500.0, 7174U); + require(evaluate(engine, + "__waapiEvents.filter(value => value === 'AbortError').length === 1" + " && __waapiEvents.filter(value => value === 'cancel').length === 1", + "bounded-waapi-cancel.js") == "true", + "detached WAAPI track did not reject and cancel exactly once"); + wait_for_animation_frame_demand( + engine, false, "detached WAAPI track retained host-frame demand"); +} + void test_transform_keyframes_compose_retained_geometry_and_paint( webscene_engine* engine) { diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp index 70ffd54e..b3800ca8 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -1144,6 +1144,7 @@ int main() test_zero_time_animation_tracks_settle_without_frame_demand(focused_engine); test_transform_keyframes_compose_retained_geometry_and_paint(focused_engine); test_generated_pseudo_animations_share_owner_lifecycle(focused_engine); + test_bounded_web_animations_share_host_clock_and_lifecycle(focused_engine); webscene_engine_destroy(focused_engine); return 0; } @@ -1645,6 +1646,7 @@ int main() test_zero_time_animation_tracks_settle_without_frame_demand(engine); test_transform_keyframes_compose_retained_geometry_and_paint(engine); test_generated_pseudo_animations_share_owner_lifecycle(engine); + test_bounded_web_animations_share_host_clock_and_lifecycle(engine); // This regression uses fixed host timestamps beginning at 2700 ms. Give it // a fresh timeline so earlier animation tests cannot make those frames stale. webscene_engine_destroy(engine); diff --git a/tests/WebPlatformSubset/capabilities.json b/tests/WebPlatformSubset/capabilities.json index cdbb955e..3990b297 100644 --- a/tests/WebPlatformSubset/capabilities.json +++ b/tests/WebPlatformSubset/capabilities.json @@ -97,11 +97,11 @@ }, { "family": "transitions-and-animation", - "required": ["host-frame-driven requestAnimationFrame", "transition longhands and delay", "four-number cubic-bezier timing", "translate, scale, rotate, opacity, foreground color, background color, supported compound filters, left and top interpolation", "background shorthand transition aliasing", "filter none identity interpolation", "logical inset transition aliasing", "intermediate computed style, paint and client geometry", "transition run/start/cancel/end events", "cancellation and reversal", "bounded paint samples", "dense bounded rotation cadence", "native opacity, translate, scale, rotate, and bounded filter keyframes", "native animation shorthand and longhands", "native staggered delay and infinite iteration", "single-animation normal, reverse, alternate, and alternate-reverse direction", "single-animation none, forwards, backwards, and both fill", "single-animation running and paused play state", "bounded comma-separated animation lists", "zero-duration and zero-iteration-count animation phases", "bounded generated ::before and ::after animation lifecycle"], + "required": ["host-frame-driven requestAnimationFrame", "transition longhands and delay", "four-number cubic-bezier timing", "translate, scale, rotate, opacity, foreground color, background color, supported compound filters, left and top interpolation", "background shorthand transition aliasing", "filter none identity interpolation", "logical inset transition aliasing", "intermediate computed style, paint and client geometry", "transition run/start/cancel/end events", "cancellation and reversal", "bounded paint samples", "dense bounded rotation cadence", "native opacity, translate, scale, rotate, and bounded filter keyframes", "native animation shorthand and longhands", "native staggered delay and infinite iteration", "single-animation normal, reverse, alternate, and alternate-reverse direction", "single-animation none, forwards, backwards, and both fill", "single-animation running and paused play state", "bounded comma-separated animation lists", "zero-duration and zero-iteration-count animation phases", "bounded generated ::before and ::after animation lifecycle", "bounded Web Animations API over retained host-clock tracks"], "candidate": ["innerHTML transition-longhand retention", "inline transition recascade and per-longhand importance", "computed transition times serialized in seconds", "synthetic click timer task ordering"], "productEvidence": ["calendar segmented-tab underline", "Code OSS Chat and session filter transitions", "Code OSS dictation brightness and incremental Chat blur keyframes", "automatically discovered three-dot loader animation", "automatically discovered rotating spinner", "animated toolbar and dialog state changes", "continuous live-resize chart layout"], - "coverage": ["contracts/segmented-transform-transition.html", "contracts/transform-none-transition.html", "contracts/paint-transitions-temporal.html", "contracts/css-filter-transition-temporal.html", "contracts/css-filter-transition-midpoint.html", "contracts/logical-inset-transition-cadence.html", "contracts/rotation-transition-cadence.html", "contracts/dynamic-transition-style-task-order.html", "contracts/css-opacity-keyframes-temporal.html", "contracts/css-animation-fill-mode-forwards.html", "contracts/css-animation-fill-mode-boundaries.html", "contracts/css-animation-direction.html", "contracts/css-animation-play-state.html", "contracts/css-animation-multiple-lists.html", "contracts/css-animation-zero-time.html", "contracts/css-animation-transform-components.html", "contracts/css-animation-pseudo-elements.html", "contracts/css-rotation-keyframes-cadence.html", "contracts/css-filter-keyframes-temporal.html", "contracts/css-filter-keyframes-midpoint.html", "CssTransformTransitionTests", "test_inline_transition_longhands_survive_dynamic_parse_and_recascade", "test_logical_inset_transition_smooths_throttled_pointer_updates", "test_filter_keyframes_use_host_clock_and_retained_paint", "test_animation_direction_maps_cycles_and_terminal_boundaries", "test_animation_fill_maps_before_after_and_preserves_timeline", "test_animation_play_state_holds_and_resumes_host_time", "test_multiple_animation_lists_coordinate_bounded_tracks", "test_zero_time_animation_tracks_settle_without_frame_demand", "test_transform_keyframes_compose_retained_geometry_and_paint", "test_generated_pseudo_animations_share_owner_lifecycle", "native host-clock transition, retained filter paint, and keyframe regressions", "hosted component automatically discovered temporal checkpoints", "hosted component intermediate viewport-publication gate"], - "gap": "Native is required to pass the bounded transform/opacity/foreground-color/background-color/filter transition slice, including events, cancellation, reversal, authored compound order, identity interpolation, paint, and cadence. Filter transitions and keyframes are limited to the retained blur, brightness, contrast, grayscale, and saturate functions and their existing list/value bounds. Bounded filter keyframes cover Code OSS dictation brightness and incremental Chat blur, including compatible compound order, none identities, finite fill, animationend, retained paint, and host-clock cleanup. The keyframe slice maps normal, reverse, alternate, and alternate-reverse through negative delays and finite, fractional, infinite, or zero iteration counts before opacity, bounded two-dimensional translate/scale/rotation, and bounded-filter sampling. Transform keyframes accept finite px/percentage translation, numeric scale, and the existing rotation grammar; compile each declaration into a replace-composed retained tuple, and publish its computed matrix, geometry, and scene paint without frame-time parsing. For positive-duration, nonzero-count animations, none, forwards, backwards, and both apply direction-aware fill boundaries to those supported properties without extending frame demand; positive-delay backwards fill samples its boundary without applying the between-keyframe timing function. Running and paused play state now hold and resume the same bounded host-clock timeline, including delay, direction, fill, supported-property paint, exactly-once completion, and zero paused frame demand. Up to eight comma-separated animations coordinate repeated longhand lists with independent clocks, fill, pause, ordered completion, and last-contributing replace composition for the supported properties. Zero duration or zero iteration count resolves a zero active duration with directed before/after fill, bounded positive or negative delay, ordered exactly-once completion, same-update mutation settlement, and no sustained frame demand. The candidate dynamic-style reduction passes Chrome and native and closes Bootstrap Modal's static-backdrop callback guard by retaining inline transition longhands through parser construction and recascade, honoring per-longhand importance, and exposing computed times in seconds. Arbitrary transform-list matrix multiplication, 3D and additive animation composition, other animated properties, animation start/iteration/cancel event expansion, more than eight live tracks per element, and the complete Web Animations API are not claimed." + "coverage": ["contracts/segmented-transform-transition.html", "contracts/transform-none-transition.html", "contracts/paint-transitions-temporal.html", "contracts/css-filter-transition-temporal.html", "contracts/css-filter-transition-midpoint.html", "contracts/logical-inset-transition-cadence.html", "contracts/rotation-transition-cadence.html", "contracts/dynamic-transition-style-task-order.html", "contracts/css-opacity-keyframes-temporal.html", "contracts/css-animation-fill-mode-forwards.html", "contracts/css-animation-fill-mode-boundaries.html", "contracts/css-animation-direction.html", "contracts/css-animation-play-state.html", "contracts/css-animation-multiple-lists.html", "contracts/css-animation-zero-time.html", "contracts/css-animation-transform-components.html", "contracts/css-animation-pseudo-elements.html", "contracts/bounded-web-animations.html", "contracts/css-rotation-keyframes-cadence.html", "contracts/css-filter-keyframes-temporal.html", "contracts/css-filter-keyframes-midpoint.html", "CssTransformTransitionTests", "test_inline_transition_longhands_survive_dynamic_parse_and_recascade", "test_logical_inset_transition_smooths_throttled_pointer_updates", "test_filter_keyframes_use_host_clock_and_retained_paint", "test_animation_direction_maps_cycles_and_terminal_boundaries", "test_animation_fill_maps_before_after_and_preserves_timeline", "test_animation_play_state_holds_and_resumes_host_time", "test_multiple_animation_lists_coordinate_bounded_tracks", "test_zero_time_animation_tracks_settle_without_frame_demand", "test_transform_keyframes_compose_retained_geometry_and_paint", "test_generated_pseudo_animations_share_owner_lifecycle", "test_bounded_web_animations_share_host_clock_and_lifecycle", "native host-clock transition, retained filter paint, and keyframe regressions", "hosted component automatically discovered temporal checkpoints", "hosted component intermediate viewport-publication gate"], + "gap": "Native is required to pass the bounded transform/opacity/foreground-color/background-color/filter transition slice, including events, cancellation, reversal, authored compound order, identity interpolation, paint, and cadence. Filter transitions and keyframes are limited to the retained blur, brightness, contrast, grayscale, and saturate functions and their existing list/value bounds. Bounded filter keyframes cover Code OSS dictation brightness and incremental Chat blur, including compatible compound order, none identities, finite fill, animationend, retained paint, and host-clock cleanup. The keyframe slice maps normal, reverse, alternate, and alternate-reverse through negative delays and finite, fractional, infinite, or zero iteration counts before opacity, bounded two-dimensional translate/scale/rotation, and bounded-filter sampling. Transform keyframes accept finite px/percentage translation, numeric scale, and the existing rotation grammar; compile each declaration into a replace-composed retained tuple, and publish its computed matrix, geometry, and scene paint without frame-time parsing. For positive-duration, nonzero-count animations, none, forwards, backwards, and both apply direction-aware fill boundaries to those supported properties without extending frame demand; positive-delay backwards fill samples its boundary without applying the between-keyframe timing function. Running and paused play state now hold and resume the same bounded host-clock timeline, including delay, direction, fill, supported-property paint, exactly-once completion, and zero paused frame demand. Up to eight comma-separated animations coordinate repeated longhand lists with independent clocks, fill, pause, ordered completion, and last-contributing replace composition for the supported properties. The bounded Web Animations slice exposes array keyframes for opacity and the existing 2D transform grammar, uses the same eight-track budget and host clock, and settles pause, seek, finish, cancel, detach, fill and promises without sustained static demand. Zero duration or zero iteration count resolves a zero active duration with directed before/after fill, bounded positive or negative delay, ordered exactly-once completion, same-update mutation settlement, and no sustained frame demand. The candidate dynamic-style reduction passes Chrome and native and closes Bootstrap Modal's static-backdrop callback guard by retaining inline transition longhands through parser construction and recascade, honoring per-longhand importance, and exposing computed times in seconds. Arbitrary transform-list matrix multiplication, 3D and additive animation composition, other WAAPI properties, property-indexed or partial keyframes, playback-rate mutation, custom timelines, animation start/iteration event expansion, and more than eight live tracks per element are not claimed." }, { "family": "clipping-and-rounded-borders", diff --git a/tests/WebPlatformSubset/contracts/bounded-web-animations.html b/tests/WebPlatformSubset/contracts/bounded-web-animations.html new file mode 100644 index 00000000..f4077a01 --- /dev/null +++ b/tests/WebPlatformSubset/contracts/bounded-web-animations.html @@ -0,0 +1,36 @@ + + +Bounded Web Animations host-clock contract +
+ diff --git a/tests/WebPlatformSubset/webscene-component-profile.json b/tests/WebPlatformSubset/webscene-component-profile.json index daebdd6c..58a1114d 100644 --- a/tests/WebPlatformSubset/webscene-component-profile.json +++ b/tests/WebPlatformSubset/webscene-component-profile.json @@ -1924,6 +1924,13 @@ "evidence": ["css-animation-pseudo-elements-676-242"], "reason": "WPT-aligned bounded reduction for generated ::before/::after keyframes, originating-element event ownership, retained paint, identity mutation, detach cleanup, and demand-free paused or filled state." }, + { + "path": "contracts/bounded-web-animations.html", + "type": "contract", + "capabilities": ["element-animate", "animation-and-keyframe-effect", "current-time-seek", "play-pause-cancel-finish", "finished-and-ready-promises", "element-and-document-get-animations", "retained-host-clock-sampling", "bounded-waapi-lifecycle"], + "evidence": ["bounded-web-animations-677-242"], + "reason": "WPT-aligned bounded reduction for two-to-sixteen opacity and 2D transform keyframes over the retained host clock, with replace composition, identity, promises, control, detach cleanup, shared eight-track admission, and demand-free paused or settled state." + }, { "path": "contracts/css-filter-keyframes-temporal.html", "type": "contract",