Implement renderer-backed Web Animations

This commit is contained in:
SGavrl committed 2026-08-04 08:27:12 +00:00
1 parent 0a8c0feb06
commit 2b92236a2f
6 files changed
+848 -12

No files matched your search

+142
View File
@@ -17,6 +17,9 @@ use obscura_net::{
};
use tokio::sync::Mutex;
#[cfg(feature = "render")]
use serde::Deserialize;
use crate::import_map::ImportMap;
pub type InterceptCallback = Arc<
@@ -3252,6 +3255,8 @@ pub fn build_extension() -> Extension {
ops.push(op_element_scroll_to());
ops.push(op_scroll_offset());
ops.push(op_scroll_to());
ops.push(op_waapi_create());
ops.push(op_waapi_control());
}
Extension {
name: "obscura_dom",
@@ -3260,6 +3265,143 @@ pub fn build_extension() -> Extension {
}
}
#[cfg(feature = "render")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WaapiCreateInput {
id: u64,
node: u32,
keyframes: Vec<WaapiKeyframeInput>,
duration: f32,
delay: f32,
iterations: f32,
fill: String,
direction: String,
easing_bezier: Option<[f32; 4]>,
linear_easing: Option<Vec<f32>>,
}
#[cfg(feature = "render")]
#[derive(Deserialize)]
struct WaapiKeyframeInput {
offset: f32,
opacity: Option<f32>,
transform: Option<String>,
}
#[cfg(feature = "render")]
fn waapi_document_time_ms(state: &ObscuraState) -> f32 {
state.animation_timeline_origin.elapsed().as_secs_f32() * 1000.0
}
#[cfg(feature = "render")]
fn invalidate_waapi_render(state: &mut ObscuraState) {
state.prepared_render = None;
state.resolved_scroll = None;
state.activity_generation = state.activity_generation.wrapping_add(1);
}
#[cfg(feature = "render")]
#[op2(fast)]
fn op_waapi_create(state: &OpState, #[string] input: &str) -> bool {
let Ok(input) = serde_json::from_str::<WaapiCreateInput>(input) else {
return false;
};
if !input.duration.is_finite()
|| input.duration < 0.0
|| !input.delay.is_finite()
|| !input.iterations.is_finite()
|| input.iterations < 0.0
|| input.keyframes.is_empty()
{
return false;
}
let shared = state.borrow::<SharedState>().clone();
let mut state = shared.borrow_mut();
let node = NodeId::new(input.node);
if state.dom.as_ref().and_then(|dom| dom.get_node(node)).is_none() {
return false;
}
let start_time_ms = waapi_document_time_ms(&state);
let fill_mode = match input.fill.as_str() {
"forwards" => obscura_render::AnimationFillMode::Forwards,
"backwards" => obscura_render::AnimationFillMode::Backwards,
"both" => obscura_render::AnimationFillMode::Both,
_ => obscura_render::AnimationFillMode::None,
};
let direction = match input.direction.as_str() {
"reverse" => obscura_render::AnimationDirection::Reverse,
"alternate" => obscura_render::AnimationDirection::Alternate,
"alternate-reverse" => obscura_render::AnimationDirection::AlternateReverse,
_ => obscura_render::AnimationDirection::Normal,
};
state.animation_timeline.register_waapi(obscura_render::WaapiAnimation {
id: input.id,
node,
keyframes: input.keyframes.into_iter().map(|frame| obscura_render::WaapiKeyframe {
offset: frame.offset.clamp(0.0, 1.0),
opacity: frame.opacity.map(|value| value.clamp(0.0, 1.0)),
transform: frame.transform,
}).collect(),
timing: obscura_render::AnimationTiming {
duration_ms: input.duration,
delay_ms: input.delay,
iteration_count: input.iterations,
direction,
fill_mode,
play_state: obscura_render::AnimationPlayState::Running,
},
easing: input.easing_bezier,
linear_easing: input.linear_easing,
start_time_ms,
hold_time_ms: None,
play_state: obscura_render::WaapiPlayState::Running,
});
invalidate_waapi_render(&mut state);
true
}
#[cfg(feature = "render")]
#[op2(fast)]
fn op_waapi_control(
state: &OpState,
id: f64,
#[string] action: &str,
value: f64,
) -> bool {
if !id.is_finite() || id < 0.0 {
return false;
}
let shared = state.borrow::<SharedState>().clone();
let mut state = shared.borrow_mut();
let id = id as u64;
let document_time = waapi_document_time_ms(&state);
let changed = match action {
"cancel" => state.animation_timeline.cancel_waapi(id),
"finish" => state.animation_timeline.finish_waapi(id),
"pause" => state.animation_timeline.set_waapi_play_state(
id,
obscura_render::WaapiPlayState::Paused,
document_time,
),
"play" => state.animation_timeline.set_waapi_play_state(
id,
obscura_render::WaapiPlayState::Running,
document_time,
),
"currentTime" if value.is_finite() => state.animation_timeline.set_waapi_current_time(
id,
document_time,
value as f32,
),
_ => false,
};
if changed {
invalidate_waapi_render(&mut state);
}
changed
}
#[cfg(feature = "render")]
pub(crate) fn document_base_url(state: &ObscuraState) -> Option<String> {
let document_url = url::Url::parse(&state.url).ok()?;
+62
View File
@@ -6723,6 +6723,68 @@ mod tests {
);
}
#[cfg(feature = "render")]
#[test]
fn waapi_pause_seek_and_cancel_preserve_authored_inline_style() {
let mut rt = setup_runtime(
r#"<html><body><div id="box" style="opacity:.2;width:20px;height:20px"></div></body></html>"#,
);
rt.execute_script(
"waapi",
r#"
globalThis.box = document.getElementById('box');
globalThis.__animation = box.animate(
[{opacity:.2, transform:'translateX(0px)'}, {opacity:1, transform:'translateX(100px)'}],
{duration:100, fill:'both', easing:'linear'}
);
__animation.pause();
__animation.currentTime = 50;
"#,
).unwrap();
assert_eq!(rt.evaluate("box.style.opacity").unwrap(), serde_json::json!(".2"));
assert_eq!(rt.evaluate("box.getAnimations()[0] === __animation").unwrap(), serde_json::json!(true));
assert_eq!(rt.evaluate("document.getAnimations()[0] === __animation").unwrap(), serde_json::json!(true));
assert_eq!(rt.evaluate("__animation.playState").unwrap(), serde_json::json!("paused"));
assert_eq!(
rt.evaluate("!('easingBezier' in __animation.effect.getTiming()) && !('linearEasing' in __animation.effect.getComputedTiming())").unwrap(),
serde_json::json!(true),
);
let opacity = rt.evaluate("getComputedStyle(box).opacity").unwrap();
let opacity = opacity.as_str().unwrap().parse::<f32>().unwrap();
assert!((opacity - 0.6).abs() < 0.001, "midpoint opacity was {opacity}");
rt.execute_script("cancel", "__animation.cancel()").unwrap();
assert_eq!(rt.evaluate("box.style.opacity").unwrap(), serde_json::json!(".2"));
assert_eq!(rt.evaluate("getComputedStyle(box).opacity").unwrap(), serde_json::json!("0.2"));
assert_eq!(rt.evaluate("box.getAnimations().length").unwrap(), serde_json::json!(0.0));
assert_eq!(rt.evaluate("document.getAnimations().length").unwrap(), serde_json::json!(0.0));
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn waapi_zero_duration_finishes_asynchronously_and_fires_lifecycle() {
let mut rt = setup_runtime(r#"<div id="box" style="opacity:.1"></div>"#);
rt.execute_script(
"waapi-lifecycle",
r#"
globalThis.box = document.getElementById('box');
globalThis.__ready = false;
globalThis.__finished = false;
globalThis.__finishEvent = false;
globalThis.__animation = box.animate([{opacity:.1}, {opacity:1}], {duration:0, fill:'both'});
__animation.onfinish = () => { __finishEvent = true; };
__animation.ready.then(() => { __ready = true; });
__animation.finished.then(() => { __finished = true; });
"#,
).unwrap();
rt.run_event_loop_bounded(20).await.unwrap();
assert_eq!(rt.evaluate("__ready").unwrap(), serde_json::json!(true));
assert_eq!(rt.evaluate("__finished").unwrap(), serde_json::json!(true));
assert_eq!(rt.evaluate("__finishEvent").unwrap(), serde_json::json!(true));
assert_eq!(rt.evaluate("__animation.playState").unwrap(), serde_json::json!("finished"));
assert_eq!(rt.evaluate("getComputedStyle(box).opacity").unwrap(), serde_json::json!("1"));
}
#[cfg(feature = "render")]
#[test]
fn forward_animation_samples_retain_static_prepared_render() {