Implement renderer-backed Web Animations
This commit is contained in:
1 parent
0a8c0feb06
commit
2b92236a2f
6 files changed
+848
-12
No files matched your search
@@ -3664,6 +3664,13 @@ impl Stylesheet {
|
||||
animation_timeline.clear_animation(nid, animation_sample);
|
||||
}
|
||||
|
||||
// Web Animations contribute at the animation cascade origin: above
|
||||
// every normal author declaration (including inline style), but below
|
||||
// author !important. Keep the renderer-side effect separate from the
|
||||
// authored declaration block so cancel() reveals the exact underlying
|
||||
// value and CSSOM never observes a synthetic inline rewrite.
|
||||
sample_waapi_properties(animation_timeline, nid, style, animation_sample);
|
||||
|
||||
for &(_, _, i) in &important_matched {
|
||||
let rule = &self.rules[i];
|
||||
let expanded =
|
||||
@@ -3677,6 +3684,151 @@ impl Stylesheet {
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_waapi_properties(
|
||||
timeline: &crate::AnimationTimelineState,
|
||||
node: NodeId,
|
||||
style: &mut LayoutStyle,
|
||||
sample: crate::AnimationSample,
|
||||
) {
|
||||
for (animation, local_time) in timeline.waapi_for_node(node, sample.time) {
|
||||
let Some(mut progress) = animation_directed_progress(&animation.timing, local_time) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(samples) = animation.linear_easing.as_deref() {
|
||||
progress = sample_linear_easing(samples, progress);
|
||||
} else if let Some(points) = animation.easing {
|
||||
progress = sample_cubic_bezier(points, progress);
|
||||
}
|
||||
let underlying = style.clone();
|
||||
|
||||
let opacity_track = animation
|
||||
.keyframes
|
||||
.iter()
|
||||
.filter_map(|frame| frame.opacity.map(|value| (frame.offset, value)))
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(value) = sample_numeric_waapi_track(
|
||||
&opacity_track,
|
||||
underlying.opacity.unwrap_or(1.0),
|
||||
progress,
|
||||
) {
|
||||
style.opacity = Some(value.clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
let transform_track = animation
|
||||
.keyframes
|
||||
.iter()
|
||||
.filter_map(|frame| {
|
||||
let value = frame.transform.as_ref()?;
|
||||
if !crate::style::supports_declaration("transform", value) {
|
||||
return None;
|
||||
}
|
||||
let mut endpoint = underlying.clone();
|
||||
crate::style::apply_animation_property_value(&mut endpoint, "transform", value);
|
||||
Some((frame.offset, endpoint.transform_ops))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(value) = sample_transform_waapi_track(
|
||||
&transform_track,
|
||||
underlying.transform_ops.clone(),
|
||||
progress,
|
||||
) {
|
||||
apply_animation_value(
|
||||
style,
|
||||
AnimatedProperty::Transform,
|
||||
AnimationValue::Transform(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_linear_easing(samples: &[f32], progress: f32) -> f32 {
|
||||
if samples.len() < 2 {
|
||||
return progress;
|
||||
}
|
||||
let scaled = progress.clamp(0.0, 1.0) * (samples.len() - 1) as f32;
|
||||
let index = (scaled.floor() as usize).min(samples.len() - 2);
|
||||
let local = scaled - index as f32;
|
||||
samples[index] + (samples[index + 1] - samples[index]) * local
|
||||
}
|
||||
|
||||
fn sample_cubic_bezier(points: [f32; 4], progress: f32) -> f32 {
|
||||
let [x1, y1, x2, y2] = points;
|
||||
let target = progress.clamp(0.0, 1.0);
|
||||
let component = |t: f32, first: f32, second: f32| {
|
||||
let inverse = 1.0 - t;
|
||||
3.0 * inverse * inverse * t * first + 3.0 * inverse * t * t * second + t * t * t
|
||||
};
|
||||
// x control points are constrained to [0,1], so bisection is stable even
|
||||
// for flat derivatives at the ends.
|
||||
let (mut low, mut high) = (0.0, 1.0);
|
||||
for _ in 0..14 {
|
||||
let middle = (low + high) * 0.5;
|
||||
if component(middle, x1, x2) < target {
|
||||
low = middle;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
component((low + high) * 0.5, y1, y2).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn sample_numeric_waapi_track(
|
||||
track: &[(f32, f32)],
|
||||
underlying: f32,
|
||||
progress: f32,
|
||||
) -> Option<f32> {
|
||||
sample_waapi_track(track, underlying, progress, |from, to, position| {
|
||||
from + (to - from) * position
|
||||
})
|
||||
}
|
||||
|
||||
fn sample_transform_waapi_track(
|
||||
track: &[(f32, Vec<crate::TransformOp>)],
|
||||
underlying: Vec<crate::TransformOp>,
|
||||
progress: f32,
|
||||
) -> Option<Vec<crate::TransformOp>> {
|
||||
sample_waapi_track(track, underlying, progress, |from, to, position| {
|
||||
interpolate_transform_list(from, to, position)
|
||||
})
|
||||
}
|
||||
|
||||
fn sample_waapi_track<T: Clone>(
|
||||
track: &[(f32, T)],
|
||||
underlying: T,
|
||||
progress: f32,
|
||||
interpolate: impl Fn(&T, &T, f32) -> T,
|
||||
) -> Option<T> {
|
||||
if track.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut resolved = track.to_vec();
|
||||
resolved.sort_by(|left, right| left.0.total_cmp(&right.0));
|
||||
if resolved[0].0 > 0.0 {
|
||||
resolved.insert(0, (0.0, underlying.clone()));
|
||||
}
|
||||
if resolved.last().is_some_and(|stop| stop.0 < 1.0) {
|
||||
resolved.push((1.0, underlying));
|
||||
}
|
||||
if progress <= resolved[0].0 {
|
||||
return Some(resolved[0].1.clone());
|
||||
}
|
||||
for pair in resolved.windows(2) {
|
||||
let (from_offset, from) = (&pair[0].0, &pair[0].1);
|
||||
let (to_offset, to) = (&pair[1].0, &pair[1].1);
|
||||
if progress <= *to_offset {
|
||||
if progress == *to_offset || from_offset == to_offset {
|
||||
return Some(to.clone());
|
||||
}
|
||||
return Some(interpolate(
|
||||
from,
|
||||
to,
|
||||
(progress - *from_offset) / (*to_offset - *from_offset),
|
||||
));
|
||||
}
|
||||
}
|
||||
resolved.last().map(|stop| stop.1.clone())
|
||||
}
|
||||
|
||||
/// Compile a keyframes body into sparse per-property tracks. Values remain in
|
||||
/// specified form because `var()` and color-scheme resolution are element
|
||||
/// dependent, but declaration splitting, shorthand-to-longhand membership,
|
||||
@@ -7798,6 +7950,79 @@ mod tests {
|
||||
assert_eq!(style.background_color, Some([0, 0, 255, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waapi_samples_opacity_and_transform_without_rewriting_inline_cascade() {
|
||||
let tree = obscura_dom::parse_html(r#"<div id="target" style="opacity:.2"></div>"#);
|
||||
let target = tree.get_element_by_id("target").unwrap();
|
||||
let sheet = Stylesheet::parse_for_viewport(&tree, &[], (800.0, 600.0));
|
||||
let mut timeline = crate::AnimationTimelineState::default();
|
||||
timeline.register_waapi(crate::WaapiAnimation {
|
||||
id: 1,
|
||||
node: target,
|
||||
keyframes: vec![
|
||||
crate::WaapiKeyframe { offset: 0.0, opacity: Some(0.2), transform: Some("translateX(0px)".into()) },
|
||||
crate::WaapiKeyframe { offset: 1.0, opacity: Some(1.0), transform: Some("translateX(100px)".into()) },
|
||||
],
|
||||
timing: crate::AnimationTiming {
|
||||
duration_ms: 100.0,
|
||||
fill_mode: crate::AnimationFillMode::Both,
|
||||
..Default::default()
|
||||
},
|
||||
easing: None,
|
||||
linear_easing: None,
|
||||
start_time_ms: 0.0,
|
||||
hold_time_ms: Some(50.0),
|
||||
play_state: crate::WaapiPlayState::Paused,
|
||||
});
|
||||
let node = tree.get_node(target).unwrap();
|
||||
let element = node.as_element().unwrap();
|
||||
let mut matcher = tree.matcher();
|
||||
let mut style = LayoutStyle::default();
|
||||
sheet.apply_at_animation_time(
|
||||
&tree,
|
||||
&mut matcher,
|
||||
target,
|
||||
node.get_attribute("id"),
|
||||
&[],
|
||||
element.local.as_ref(),
|
||||
&mut style,
|
||||
&HashMap::new(),
|
||||
node.get_attribute("style"),
|
||||
crate::AnimationSample::document(50.0),
|
||||
&mut timeline,
|
||||
);
|
||||
assert_opacity(style.opacity.unwrap(), 0.6);
|
||||
assert!(matches!(style.transform_ops.as_slice(),
|
||||
[crate::TransformOp::Translate(x, _)] if x.value == crate::Dimension::Px(50.0)));
|
||||
|
||||
let important_tree = obscura_dom::parse_html(
|
||||
r#"<div id="target" style="opacity:.2 !important"></div>"#,
|
||||
);
|
||||
let target = important_tree.get_element_by_id("target").unwrap();
|
||||
let sheet = Stylesheet::parse_for_viewport(&important_tree, &[], (800.0, 600.0));
|
||||
let mut timeline = crate::AnimationTimelineState::default();
|
||||
timeline.register_waapi(crate::WaapiAnimation {
|
||||
id: 2,
|
||||
node: target,
|
||||
keyframes: vec![crate::WaapiKeyframe { offset: 1.0, opacity: Some(1.0), transform: None }],
|
||||
timing: crate::AnimationTiming { duration_ms: 100.0, fill_mode: crate::AnimationFillMode::Both, ..Default::default() },
|
||||
easing: None,
|
||||
linear_easing: None,
|
||||
start_time_ms: 0.0,
|
||||
hold_time_ms: Some(100.0),
|
||||
play_state: crate::WaapiPlayState::Finished,
|
||||
});
|
||||
let node = important_tree.get_node(target).unwrap();
|
||||
let mut matcher = important_tree.matcher();
|
||||
let mut style = LayoutStyle::default();
|
||||
sheet.apply_at_animation_time(
|
||||
&important_tree, &mut matcher, target, node.get_attribute("id"), &[], "div",
|
||||
&mut style, &HashMap::new(), node.get_attribute("style"),
|
||||
crate::AnimationSample::document(100.0), &mut timeline,
|
||||
);
|
||||
assert_opacity(style.opacity.unwrap(), 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_keyframes_and_empty_later_definitions_follow_browser_selection() {
|
||||
let css = r#"
|
||||
|
||||
@@ -1711,6 +1711,41 @@ struct AnimationInstance {
|
||||
was_paused: bool,
|
||||
}
|
||||
|
||||
/// A normalized property keyframe supplied through the Web Animations API.
|
||||
/// Values stay in specified form until cascade time because transforms may
|
||||
/// contain relative units whose meaning depends on the animated element.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WaapiKeyframe {
|
||||
pub offset: f32,
|
||||
pub opacity: Option<f32>,
|
||||
pub transform: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WaapiPlayState {
|
||||
Running,
|
||||
Paused,
|
||||
Finished,
|
||||
}
|
||||
|
||||
/// Renderer-owned WAAPI effect. JavaScript keeps the wrapper object identity,
|
||||
/// while this document-scoped record is the source of truth for cascade and
|
||||
/// paint. It deliberately does not rewrite the element's inline style.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WaapiAnimation {
|
||||
pub id: u64,
|
||||
pub node: obscura_dom::tree::NodeId,
|
||||
pub keyframes: Vec<WaapiKeyframe>,
|
||||
pub timing: AnimationTiming,
|
||||
/// Cubic Bezier timing function control points. `None` is linear.
|
||||
pub easing: Option<[f32; 4]>,
|
||||
/// CSS `linear()` output samples at evenly distributed input positions.
|
||||
pub linear_easing: Option<Vec<f32>>,
|
||||
pub start_time_ms: f32,
|
||||
pub hold_time_ms: Option<f32>,
|
||||
pub play_state: WaapiPlayState,
|
||||
}
|
||||
|
||||
/// Page-owned CSS animation instance history retained across layout rebuilds.
|
||||
/// Node ids are document-scoped, so navigation must replace this value.
|
||||
#[derive(Debug, Default)]
|
||||
@@ -1718,6 +1753,7 @@ pub struct AnimationTimelineState {
|
||||
instances: std::collections::HashMap<obscura_dom::tree::NodeId, AnimationInstance>,
|
||||
start_candidates: std::collections::HashMap<obscura_dom::tree::NodeId, f32>,
|
||||
subtree_start_candidates: std::collections::HashMap<obscura_dom::tree::NodeId, f32>,
|
||||
waapi: std::collections::BTreeMap<u64, WaapiAnimation>,
|
||||
}
|
||||
|
||||
impl AnimationTimelineState {
|
||||
@@ -1765,9 +1801,91 @@ impl AnimationTimelineState {
|
||||
self.instances.remove(node);
|
||||
self.start_candidates.remove(node);
|
||||
self.subtree_start_candidates.remove(node);
|
||||
self.waapi.retain(|_, animation| animation.node != *node);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_waapi(&mut self, animation: WaapiAnimation) {
|
||||
self.waapi.insert(animation.id, animation);
|
||||
}
|
||||
|
||||
pub fn cancel_waapi(&mut self, id: u64) -> bool {
|
||||
self.waapi.remove(&id).is_some()
|
||||
}
|
||||
|
||||
pub fn set_waapi_current_time(&mut self, id: u64, document_time_ms: f32, local_time_ms: f32) -> bool {
|
||||
let Some(animation) = self.waapi.get_mut(&id) else { return false; };
|
||||
let local = local_time_ms.max(0.0);
|
||||
animation.hold_time_ms = Some(local);
|
||||
animation.start_time_ms = document_time_ms - local;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn set_waapi_play_state(
|
||||
&mut self,
|
||||
id: u64,
|
||||
state: WaapiPlayState,
|
||||
document_time_ms: f32,
|
||||
) -> bool {
|
||||
let Some(animation) = self.waapi.get_mut(&id) else { return false; };
|
||||
let current = animation
|
||||
.hold_time_ms
|
||||
.unwrap_or_else(|| (document_time_ms - animation.start_time_ms).max(0.0));
|
||||
match state {
|
||||
WaapiPlayState::Running => {
|
||||
animation.start_time_ms = document_time_ms - current;
|
||||
animation.hold_time_ms = None;
|
||||
}
|
||||
WaapiPlayState::Paused | WaapiPlayState::Finished => {
|
||||
animation.hold_time_ms = Some(current);
|
||||
}
|
||||
}
|
||||
animation.play_state = state;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn finish_waapi(&mut self, id: u64) -> bool {
|
||||
let Some(animation) = self.waapi.get_mut(&id) else { return false; };
|
||||
let end = animation.timing.delay_ms
|
||||
+ animation.timing.duration_ms * animation.timing.iteration_count.max(0.0);
|
||||
animation.hold_time_ms = Some(end.max(0.0));
|
||||
animation.play_state = WaapiPlayState::Finished;
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn waapi_for_node(
|
||||
&self,
|
||||
node: obscura_dom::tree::NodeId,
|
||||
document_time: AnimationSampleTime,
|
||||
) -> impl Iterator<Item = (&WaapiAnimation, AnimationSampleTime)> {
|
||||
self.waapi.values().filter_map(move |animation| {
|
||||
if animation.node != node {
|
||||
return None;
|
||||
}
|
||||
let local = animation.hold_time_ms.unwrap_or_else(|| {
|
||||
(document_time.milliseconds - animation.start_time_ms).max(0.0)
|
||||
});
|
||||
Some((animation, AnimationSampleTime { milliseconds: local }))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn has_active_waapi(&self, document_time: AnimationSampleTime) -> bool {
|
||||
self.waapi.values().any(|animation| {
|
||||
if animation.play_state != WaapiPlayState::Running
|
||||
|| animation.timing.duration_ms <= 0.0
|
||||
|| animation.timing.iteration_count <= 0.0
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let local = animation.hold_time_ms.unwrap_or_else(|| {
|
||||
(document_time.milliseconds - animation.start_time_ms).max(0.0)
|
||||
});
|
||||
let end = animation.timing.delay_ms
|
||||
+ animation.timing.duration_ms * animation.timing.iteration_count;
|
||||
local < end.max(0.0)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn sample_for(
|
||||
&mut self,
|
||||
node: obscura_dom::tree::NodeId,
|
||||
@@ -1832,6 +1950,7 @@ impl AnimationTimelineState {
|
||||
self.instances.retain(|node, _| keep(*node));
|
||||
self.start_candidates.retain(|node, _| keep(*node));
|
||||
self.subtree_start_candidates.retain(|node, _| keep(*node));
|
||||
self.waapi.retain(|_, animation| keep(animation.node));
|
||||
}
|
||||
|
||||
pub fn clear_start_candidates(&mut self) {
|
||||
|
||||
@@ -756,6 +756,7 @@ impl ResolvedScrollState {
|
||||
pub struct PreparedRender {
|
||||
viewport: (f32, f32),
|
||||
animation_sample: crate::AnimationSample,
|
||||
has_active_waapi_animations: bool,
|
||||
root_font_size: f32,
|
||||
base_url: Option<String>,
|
||||
content_size: (f32, f32),
|
||||
@@ -784,7 +785,7 @@ impl PreparedRender {
|
||||
/// animation. Finite animations stop producing compositor damage after
|
||||
/// their active interval; paused and zero-duration animations never do.
|
||||
pub fn has_active_css_animations(&self) -> bool {
|
||||
self.layout.styles.values().any(|style| {
|
||||
self.has_active_waapi_animations || self.layout.styles.values().any(|style| {
|
||||
if style.animation_name.is_none()
|
||||
|| !style.animation_has_render_effect
|
||||
|| style.animation_timing.play_state == crate::AnimationPlayState::Paused
|
||||
@@ -2190,6 +2191,7 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
|
||||
Some(PreparedRender {
|
||||
viewport,
|
||||
animation_sample,
|
||||
has_active_waapi_animations: animation_timeline.has_active_waapi(animation_sample.time),
|
||||
root_font_size,
|
||||
base_url: base_url.map(str::to_string),
|
||||
content_size,
|
||||
|
||||
Reference in new issue
Block a user