diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index e739ebd..c941108 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -3427,18 +3427,22 @@ pub(crate) fn ensure_prepared_render( let base_url = document_base_url(state); let viewport = state.viewport; let animation_sample = state.animation_sample; - let stale = state.prepared_render.as_ref().map_or(true, |prepared| { + let incompatible = state.prepared_render.as_ref().is_some_and(|prepared| { prepared.viewport() != viewport || prepared.base_url() != base_url.as_deref() - || prepared.animation_sample() != animation_sample }); - if stale || !state.pending_style_mutations.is_empty() { + let needs_rebuild = state.prepared_render.as_ref().map_or(true, |prepared| { + incompatible || prepared.animation_sample() != animation_sample + }) || !state.pending_style_mutations.is_empty(); + if needs_rebuild { if let Some(dom) = state.dom.as_ref() { state .animation_timeline .materialize_start_candidates(dom); } - let previous = (!stale).then(|| state.prepared_render.take()).flatten(); + let previous = (!incompatible) + .then(|| state.prepared_render.take()) + .flatten(); let mutations = std::mem::take(&mut state.pending_style_mutations); let prepared = { let dom = state.dom.as_ref()?; @@ -3520,9 +3524,15 @@ pub(crate) fn sample_live_document_animations(state: &mut ObscuraState) { state.animation_sample = sample; return; } + let forward_document_sample = + sample.mode == obscura_render::AnimationSampleMode::DocumentTime + && state.animation_sample.mode == obscura_render::AnimationSampleMode::DocumentTime + && sample.time.milliseconds > state.animation_sample.time.milliseconds; state.animation_sample = sample; - state.prepared_render = None; - state.pending_style_mutations.clear(); + if !forward_document_sample { + state.prepared_render = None; + state.pending_style_mutations.clear(); + } state.resolved_scroll = None; } diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 9998b47..81fcdf0 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -471,9 +471,11 @@ impl ObscuraJsRuntime { } let mut state = self.state.borrow_mut(); if state.animation_sample != sample { - if sample.mode == obscura_render::AnimationSampleMode::DocumentTime + let forward_document_sample = + sample.mode == obscura_render::AnimationSampleMode::DocumentTime && state.animation_sample.mode == obscura_render::AnimationSampleMode::DocumentTime - && sample.time.milliseconds > state.animation_sample.time.milliseconds + && sample.time.milliseconds > state.animation_sample.time.milliseconds; + if forward_document_sample && state.prepared_render.as_mut().is_some_and(|prepared| { prepared.advance_inactive_animation_sample_time(sample.time) }) @@ -482,8 +484,10 @@ impl ObscuraJsRuntime { return true; } state.animation_sample = sample; - state.prepared_render = None; - state.pending_style_mutations.clear(); + if !forward_document_sample { + state.prepared_render = None; + state.pending_style_mutations.clear(); + } state.resolved_scroll = None; } true @@ -6879,6 +6883,100 @@ mod tests { assert_eq!(first, second); } + #[cfg(feature = "render")] + #[test] + fn forward_active_animation_sample_updates_geometry_and_paint_from_retained_frame() { + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(parse_html( + r#"
"#, + )); + rt.set_url("http://example.test/page"); + rt.set_viewport(120.0, 40.0); + rt.run_page_init(); + + assert!(rt.set_animation_sample(obscura_render::AnimationSample::document(0.0))); + let initial = rt + .screenshot_prepared((120.0, 40.0), Some("http://example.test/page")) + .expect("initial animation frame"); + assert!((animation_test_width(&rt, "box") - 20.0).abs() < 0.1); + + assert!(rt.set_animation_sample(obscura_render::AnimationSample::document(500.0))); + assert!( + rt.state.borrow().prepared_render.is_some(), + "a forward active sample should retain the previous style graph until flush" + ); + let midpoint = rt + .screenshot_prepared((120.0, 40.0), Some("http://example.test/page")) + .expect("retained midpoint animation frame"); + + assert!((animation_test_width(&rt, "box") - 60.0).abs() < 0.1); + assert_ne!(initial, midpoint, "animated paint output must advance"); + assert_eq!( + rt.state + .borrow() + .prepared_render + .as_ref() + .unwrap() + .animation_sample_time() + .milliseconds, + 500.0 + ); + } + + #[cfg(feature = "render")] + #[test] + fn forward_waapi_sample_updates_retained_style_and_paint() { + let mut rt = setup_runtime( + r#" +
+ "#, + ); + rt.set_viewport(120.0, 40.0); + rt.state.borrow_mut().animation_timeline_origin = std::time::Instant::now(); + assert!(rt.set_animation_sample(obscura_render::AnimationSample::document(0.0))); + rt.execute_script( + "waapi-retained-frame", + r#"document.getElementById('box').animate( + [{opacity:0,transform:'translateX(0px)'}, + {opacity:1,transform:'translateX(80px)'}], + {duration:1000,fill:'both',easing:'linear'} + )"#, + ) + .unwrap(); + let initial = rt + .screenshot_prepared((120.0, 40.0), Some("http://example.com/test")) + .expect("initial WAAPI frame"); + + assert!(rt.set_animation_sample(obscura_render::AnimationSample::document(500.0))); + assert!( + rt.state.borrow().prepared_render.is_some(), + "a forward WAAPI sample should preserve the prepared style graph until flush" + ); + let midpoint = rt + .screenshot_prepared((120.0, 40.0), Some("http://example.com/test")) + .expect("retained WAAPI midpoint"); + let midpoint_opacity = { + let state = rt.state.borrow(); + let dom = state.dom.as_ref().unwrap(); + let box_node = dom.get_element_by_id("box").unwrap(); + state.prepared_render.as_ref().unwrap().layout().styles[&box_node] + .opacity + .unwrap() + }; + + assert!( + (0.45..0.55).contains(&midpoint_opacity), + "WAAPI midpoint opacity={midpoint_opacity}" + ); + assert_ne!(initial, midpoint, "WAAPI paint output must advance"); + } + #[cfg(feature = "render")] #[test] fn completed_animation_retains_forward_frame_but_backward_seek_rebuilds() { diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index e686c5d..54d12a5 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -540,6 +540,10 @@ pub enum TreeStyleMutation { pub enum RetainedStyleMutation { Attribute(AttributeStyleMutation), Tree(TreeStyleMutation), + /// A document-timeline sample changed while the DOM and stylesheet stayed + /// stable. Re-cascade this animation owner and its inheritance-dependent + /// subtree while retaining unrelated branches. + Animation { node: NodeId }, } impl From for RetainedStyleMutation { @@ -2594,7 +2598,10 @@ fn container_iteration_termination( } enum RetainedStylePlan { - Reuse(HashSet), + Reuse { + dirty: HashSet, + has_animation_damage: bool, + }, Full, } @@ -3126,6 +3133,7 @@ fn empty_state_may_have_changed( .iter() .filter(|mutation| match mutation { RetainedStyleMutation::Attribute(_) => false, + RetainedStyleMutation::Animation { .. } => false, RetainedStyleMutation::Tree(TreeStyleMutation::Insert { old_parent, new_parent, @@ -3189,7 +3197,17 @@ fn retained_style_plan( mutations: &[RetainedStyleMutation], ) -> RetainedStylePlan { let mut dirty = HashSet::new(); + let mut has_animation_damage = false; for mutation in mutations { + if let RetainedStyleMutation::Animation { node } = mutation { + // Animated color and visibility inherit, keyframe endpoints can + // consume inherited custom properties, and generated pseudos are + // rebuilt with their originating element. The existing subtree + // expansion covers all three while retaining sibling branches. + add_style_subtree(tree, *node, &mut dirty); + has_animation_damage = true; + continue; + } let RetainedStyleMutation::Attribute(mutation) = mutation else { let RetainedStyleMutation::Tree(mutation) = mutation else { unreachable!() @@ -3353,7 +3371,10 @@ fn retained_style_plan( } } } - RetainedStylePlan::Reuse(dirty) + RetainedStylePlan::Reuse { + dirty, + has_animation_damage, + } } pub(crate) fn layout_dom_with_web_fonts( @@ -3599,7 +3620,10 @@ fn layout_dom_with_web_fonts_pass_limit_at_animation_time( .custom_properties .retain(|node, _| connected.contains(node)); match retained_style_plan(tree, &sheet, mutations) { - RetainedStylePlan::Reuse(mut dirty) => { + RetainedStylePlan::Reuse { + mut dirty, + has_animation_damage, + } => { if !active_containers.is_empty() && sheet.has_container_queries() { let mut matcher = tree.matcher(); add_container_query_reset_scopes( @@ -3613,6 +3637,22 @@ fn layout_dom_with_web_fonts_pass_limit_at_animation_time( &mut dirty, ); } + // Once animation damage reaches at least half of the retained + // style graph, sparse HashMap reuse no longer offsets dirty-set + // bookkeeping and branch checks. This is a document-relative + // coverage threshold, not a site- or node-count heuristic. + if has_animation_damage + && dirty.len().saturating_mul(2) >= retained.styles.len() + { + if std::env::var_os("OBSCURA_RENDER_TIMING").is_some() { + eprintln!( + "[timing] retained-style fallback reason=animation-dirty-coverage dirty={} retained={}", + dirty.len(), + retained.styles.len(), + ); + } + return None; + } Some((retained, dirty)) } RetainedStylePlan::Full => None, @@ -14581,6 +14621,87 @@ mod tests { } } + #[test] + fn retained_animation_restyle_reuses_clean_branches_and_matches_full() { + let mut clean = String::new(); + for index in 0..2_000 { + clean.push_str(&format!( + "" + )); + } + let tree = parse_html(&format!( + r#"
x
+
"#, + )); + let animated = tree.get_element_by_id("animated").unwrap(); + let mut cache = crate::css::StylesheetCache::default(); + let mut timeline = crate::AnimationTimelineState::default(); + let (mut initial, _) = layout_dom_with_web_fonts_pass_limit_at_animation_time( + &tree, + (800.0, 600.0), + &HashMap::new(), + &[], + None, + Some(&mut cache), + None, + &[], + crate::AnimationSample::document(0.0), + &mut timeline, + ); + let retained = RetainedStyleMaps { + styles: std::mem::take(&mut initial.styles), + custom_properties: std::mem::take(&mut initial.custom_properties), + }; + let (incremental, telemetry) = + layout_dom_with_web_fonts_pass_limit_at_animation_time( + &tree, + (800.0, 600.0), + &HashMap::new(), + &[], + None, + Some(&mut cache), + Some(retained), + &[RetainedStyleMutation::Animation { node: animated }], + crate::AnimationSample::document(500.0), + &mut timeline, + ); + let mut full_timeline = crate::AnimationTimelineState::default(); + let (full, _) = layout_dom_with_web_fonts_pass_limit_at_animation_time( + &tree, + (800.0, 600.0), + &HashMap::new(), + &[], + None, + Some(&mut cache), + None, + &[], + crate::AnimationSample::document(500.0), + &mut full_timeline, + ); + + assert_computed_styles_match("animation retained-vs-full", &incremental, &full); + assert_eq!(incremental.rects, full.rects); + assert_eq!(telemetry.retained_fallback, 0, "{telemetry:?}"); + assert!(telemetry.retained_reused >= 4_000, "{telemetry:?}"); + assert!(telemetry.retained_fresh < 16, "{telemetry:?}"); + assert!( + (incremental.rects[&animated].width - 70.0).abs() < 0.1, + "animated geometry did not advance: {:?}", + incremental.rects[&animated] + ); + let child = tree.get_element_by_id("child").unwrap(); + assert_eq!(incremental.styles[&child].color, Some([128, 0, 128, 255])); + } + #[test] fn retained_insertions_with_active_container_queries_match_forced_full() { let mut clean = String::new(); diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index dd554c4..1134d85 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -1886,6 +1886,21 @@ impl AnimationTimelineState { self.waapi.insert(animation.id, animation); } + /// Exact set of nodes currently targeted by registered Web Animations. + /// The render preparation boundary additionally filters disconnected + /// targets against the current DOM before producing restyle damage. + pub fn waapi_nodes( + &self, + ) -> std::collections::HashSet { + self.waapi.values().map(|animation| animation.node).collect() + } + + /// New animation instance epochs must observe a complete cascade. They + /// can be created by DOM mutations which have not yet reached style flush. + pub fn has_pending_start_candidates(&self) -> bool { + !self.start_candidates.is_empty() || !self.subtree_start_candidates.is_empty() + } + pub fn cancel_waapi(&mut self, id: u64) -> bool { self.waapi.remove(&id).is_some() } diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 85f1b6a..788875b 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -2098,7 +2098,14 @@ pub fn prepare_dom_with_retained_styles_with_animation_state( animation_sample: crate::AnimationSample, animation_timeline: &mut crate::AnimationTimelineState, ) -> Option { - if previous.animation_sample != animation_sample { + let sample_changed = previous.animation_sample != animation_sample; + let forward_document_sample = sample_changed + && previous.animation_sample.mode == crate::AnimationSampleMode::DocumentTime + && animation_sample.mode == crate::AnimationSampleMode::DocumentTime + && animation_sample.time.milliseconds >= previous.animation_sample.time.milliseconds; + if sample_changed + && (!forward_document_sample || animation_timeline.has_pending_start_candidates()) + { drop(previous); return prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state( tree, @@ -2111,6 +2118,22 @@ pub fn prepare_dom_with_retained_styles_with_animation_state( animation_timeline, ); } + let animation_nodes = sample_changed + .then(|| retained_animation_restyle_nodes(tree, &previous.layout.styles, animation_timeline)) + .unwrap_or_default(); + let animation_mutations; + let mutations = if animation_nodes.is_empty() { + mutations + } else { + animation_mutations = mutations + .iter() + .cloned() + .chain(animation_nodes.into_iter().map(|node| { + crate::dom::RetainedStyleMutation::Animation { node } + })) + .collect::>(); + animation_mutations.as_slice() + }; let retained = RetainedStyleMaps { styles: std::mem::take(&mut previous.layout.styles), custom_properties: std::mem::take(&mut previous.layout.custom_properties), @@ -2129,6 +2152,26 @@ pub fn prepare_dom_with_retained_styles_with_animation_state( ) } +fn retained_animation_restyle_nodes( + tree: &DomTree, + styles: &HashMap, + animation_timeline: &crate::AnimationTimelineState, +) -> std::collections::HashSet { + let mut nodes = styles + .iter() + .filter_map(|(node, style)| { + (style.animation_name.is_some() && style.animation_has_render_effect) + .then_some(*node) + }) + .collect::>(); + nodes.extend(animation_timeline.waapi_nodes()); + nodes.retain(|node| { + tree.get_node(*node).is_some() + && (*node == tree.document() || tree.ancestors(*node).contains(&tree.document())) + }); + nodes +} + fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal( tree: &DomTree, viewport: (f32, f32), @@ -14407,6 +14450,50 @@ mod tests { assert!(sticky.blue() > 240 && sticky.red() < 20); } + #[test] + fn animation_restyle_nodes_deduplicate_waapi_and_filter_disconnected_targets() { + let tree = parse_html( + "
", + ); + let connected = tree.get_element_by_id("connected").unwrap(); + let detached = tree.get_element_by_id("detached").unwrap(); + tree.remove_child(detached); + let animation = |id, node| crate::WaapiAnimation { + id, + node, + keyframes: vec![crate::WaapiKeyframe { + offset: 0.0, + opacity: Some(0.0), + transform: None, + }], + timing: crate::AnimationTiming { + duration_ms: 1_000.0, + ..crate::AnimationTiming::default() + }, + easing: None, + linear_easing: None, + start_time_ms: 0.0, + hold_time_ms: None, + play_state: crate::WaapiPlayState::Running, + }; + let mut timeline = crate::AnimationTimelineState::default(); + timeline.register_waapi(animation(1, connected)); + timeline.register_waapi(animation(2, connected)); + timeline.register_waapi(animation(3, detached)); + assert_eq!( + timeline.waapi_nodes(), + std::collections::HashSet::from([connected, detached]), + "the timeline accessor must return the exact deduplicated target set" + ); + + let nodes = retained_animation_restyle_nodes(&tree, &HashMap::new(), &timeline); + assert_eq!( + nodes, + std::collections::HashSet::from([connected]), + "only connected WAAPI targets may enter retained style damage" + ); + } + #[test] fn prepared_render_samples_explicit_animation_time_and_reports_live_damage() { let tree = parse_html(