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

+297 -11
View File
@@ -47,9 +47,10 @@
// Pre-declaring them non-enumerable here is enough -- per the note above,
// the later `globalThis.X = X` assignments only update the value.
'Node', 'Element', 'Document', 'DocumentFragment', 'DocumentType',
'Animation', 'KeyframeEffect', 'DocumentTimeline',
'Text', 'Comment', 'CDATASection', 'ProcessingInstruction', 'CharacterData',
'CSSStyleDeclaration', 'DOMTokenList', 'NamedNodeMap', 'Screen', 'NetworkInformation',
'MessageChannel', 'MessagePort', 'CustomElementRegistry',
'MessageChannel', 'MessagePort', 'BroadcastChannel', 'CustomElementRegistry',
'XMLHttpRequestEventTarget', 'HTMLMediaElement', 'HTMLVideoElement',
'HTMLAudioElement', 'WebGL2RenderingContext',
];
@@ -2093,6 +2094,281 @@ class NamedNodeMap {
}
globalThis.NamedNodeMap = NamedNodeMap;
let _waapiNextId = 1;
const _waapiAnimations = new Set();
function _normalizeWaapiKeyframes(input) {
let frames;
if (Array.isArray(input)) {
frames = input.map(frame => ({ ...(frame || {}) }));
} else if (input && typeof input === 'object') {
const properties = Object.keys(input).filter(name => name !== 'offset' && name !== 'easing' && name !== 'composite');
const count = Math.max(1, ...properties.map(name => Array.isArray(input[name]) ? input[name].length : 1));
frames = Array.from({ length: count }, (_, index) => {
const frame = {};
for (const name of properties) {
const values = Array.isArray(input[name]) ? input[name] : [input[name]];
frame[name] = values[Math.min(index, values.length - 1)];
}
if (Array.isArray(input.offset)) frame.offset = input.offset[Math.min(index, input.offset.length - 1)];
return frame;
});
} else {
throw new TypeError('Keyframes must be an object or an array');
}
if (frames.length === 0) return [];
let previous = -Infinity;
for (let i = 0; i < frames.length; i++) {
if (frames[i].offset != null) {
const offset = Number(frames[i].offset);
if (!Number.isFinite(offset) || offset < 0 || offset > 1 || offset < previous) {
throw new TypeError('Invalid keyframe offset');
}
frames[i].offset = offset;
previous = offset;
}
}
if (frames[0].offset == null) frames[0].offset = 0;
if (frames[frames.length - 1].offset == null) frames[frames.length - 1].offset = 1;
let anchor = 0;
while (anchor < frames.length - 1) {
let next = anchor + 1;
while (next < frames.length && frames[next].offset == null) next++;
const from = frames[anchor].offset;
const to = frames[next].offset;
for (let i = anchor + 1; i < next; i++) {
frames[i].offset = from + (to - from) * ((i - anchor) / (next - anchor));
}
anchor = next;
}
return frames.map(frame => {
const normalized = { offset: frame.offset };
if (frame.opacity != null) {
const value = Number(frame.opacity);
if (Number.isFinite(value)) normalized.opacity = Math.max(0, Math.min(1, value));
}
if (frame.transform != null) normalized.transform = String(frame.transform);
return normalized;
}).filter(frame => frame.opacity != null || frame.transform != null);
}
function _normalizeWaapiTiming(options) {
if (typeof options === 'number') options = { duration: options };
options = options || {};
const duration = options.duration === 'auto' || options.duration == null ? 0 : Number(options.duration);
const delay = options.delay == null ? 0 : Number(options.delay);
const iterations = options.iterations == null ? 1 : Number(options.iterations);
if (!Number.isFinite(duration) || duration < 0 || !Number.isFinite(delay)
|| !Number.isFinite(iterations) || iterations < 0) {
throw new TypeError('Invalid animation timing');
}
const easing = options.easing == null ? 'linear' : String(options.easing).trim();
const namedBezier = {
'ease': [0.25, 0.1, 0.25, 1],
'ease-in': [0.42, 0, 1, 1],
'ease-out': [0, 0, 0.58, 1],
'ease-in-out': [0.42, 0, 0.58, 1],
};
let easingBezier = easing === 'linear' ? null : namedBezier[easing];
let linearEasing = null;
if (easing.startsWith('linear(') && easing.endsWith(')')) {
const values = easing.slice(7, -1).split(',').map(value => Number(value.trim()));
if (values.length >= 2 && values.every(Number.isFinite)) linearEasing = values;
}
if (easingBezier === undefined) {
const match = /^cubic-bezier\(\s*([-+\d.eE]+)\s*,\s*([-+\d.eE]+)\s*,\s*([-+\d.eE]+)\s*,\s*([-+\d.eE]+)\s*\)$/.exec(easing);
if (match) {
easingBezier = match.slice(1).map(Number);
if (!easingBezier.every(Number.isFinite) || easingBezier[0] < 0 || easingBezier[0] > 1
|| easingBezier[2] < 0 || easingBezier[2] > 1) easingBezier = undefined;
}
}
if (linearEasing) easingBezier = null;
// steps() and linear() with explicit stop positions remain explicit
// unsupported surfaces rather than being silently approximated.
if (easingBezier === undefined) throw new TypeError('Unsupported animation easing: ' + easing);
const fill = ['none', 'forwards', 'backwards', 'both'].includes(options.fill) ? options.fill : 'none';
const direction = ['normal', 'reverse', 'alternate', 'alternate-reverse'].includes(options.direction)
? options.direction : 'normal';
return { duration, delay, iterations, fill, direction, easing, easingBezier, linearEasing };
}
class KeyframeEffect {
constructor(target, keyframes, options) {
if (!(target instanceof Element)) throw new TypeError('KeyframeEffect target must be an Element');
this.target = target;
this._keyframes = _normalizeWaapiKeyframes(keyframes);
this._timing = _normalizeWaapiTiming(options);
}
getKeyframes() { return this._keyframes.map(frame => ({ ...frame, computedOffset: frame.offset, easing: 'linear', composite: 'auto' })); }
getTiming() {
const timing = this._timing;
return {
delay: timing.delay, endDelay: 0, fill: timing.fill,
iterationStart: 0, iterations: timing.iterations,
duration: timing.duration, direction: timing.direction, easing: timing.easing,
};
}
getComputedTiming() {
const animation = this._animation;
const local = animation ? animation.currentTime : 0;
const activeDuration = this._timing.duration * this._timing.iterations;
const endTime = this._timing.delay + activeDuration;
const progress = activeDuration > 0 ? Math.max(0, Math.min(1, (local - this._timing.delay) / activeDuration)) : null;
return {
...this.getTiming(), activeDuration, endTime, localTime: local,
progress, currentIteration: progress == null ? null : Math.min(this._timing.iterations, 1),
};
}
}
class Animation {
constructor(effect = null, timeline = globalThis.document?.timeline || null) {
this.id = '';
this.effect = effect;
this.timeline = timeline;
this.onfinish = null;
this.oncancel = null;
this._nativeId = _waapiNextId++;
this._registered = false;
this._playState = 'idle';
this._holdTime = 0;
this._startTime = null;
this._finishTimer = null;
this.ready = Promise.resolve(this);
this._resetFinishedPromise();
if (effect) effect._animation = this;
}
_resetFinishedPromise() {
this.finished = new Promise((resolve, reject) => {
this._resolveFinished = resolve;
this._rejectFinished = reject;
});
// Browser code commonly ignores the rejected cancel promise.
this.finished.catch(() => {});
}
_native(action, value = 0) {
try {
const changed = !!Deno.core.ops.op_waapi_control?.(this._nativeId, action, Number(value) || 0);
if (changed) _domMutationEpoch++;
return changed;
}
catch (_) { return false; }
}
_register() {
if (this._registered || !this.effect) return this._registered;
const input = {
id: this._nativeId,
node: this.effect.target._nid,
keyframes: this.effect._keyframes,
...this.effect._timing,
};
try { this._registered = !!Deno.core.ops.op_waapi_create?.(JSON.stringify(input)); }
catch (_) { this._registered = false; }
if (this._registered) {
_waapiAnimations.add(this);
_domMutationEpoch++;
}
return this._registered;
}
_scheduleFinish() {
if (this._finishTimer != null) clearTimeout(this._finishTimer);
if (this._playState !== 'running' || !this.effect) return;
const timing = this.effect._timing;
const end = Math.max(0, timing.delay + timing.duration * timing.iterations);
const remaining = Math.max(0, end - this.currentTime);
this._finishTimer = setTimeout(() => this.finish(), remaining);
}
get playState() { return this._playState; }
get currentTime() {
if (this._playState === 'running' && this._startTime != null) return Math.max(0, performance.now() - this._startTime);
return this._holdTime;
}
set currentTime(value) {
const time = Math.max(0, Number(value) || 0);
this._holdTime = time;
if (this._playState === 'running') this._startTime = performance.now() - time;
this._native('currentTime', time);
this._scheduleFinish();
}
get startTime() { return this._startTime; }
set startTime(value) {
if (value == null) { this._startTime = null; return; }
const start = Number(value);
if (!Number.isFinite(start)) throw new TypeError('Invalid startTime');
this._startTime = start;
this._holdTime = Math.max(0, performance.now() - start);
this._native('currentTime', this._holdTime);
this._scheduleFinish();
}
play() {
if (!this.effect) return;
if (this._playState === 'finished' || this._playState === 'idle') {
this._holdTime = 0;
if (this._playState === 'finished') this._resetFinishedPromise();
}
this._register();
this._startTime = performance.now() - this._holdTime;
this._playState = 'running';
this._native('play');
this.ready = Promise.resolve(this);
this._scheduleFinish();
}
pause() {
if (this._playState === 'idle') this._register();
this._holdTime = this.currentTime;
this._playState = 'paused';
this._native('currentTime', this._holdTime);
this._native('pause');
if (this._finishTimer != null) clearTimeout(this._finishTimer);
}
finish() {
if (!this.effect) return;
this._register();
const timing = this.effect._timing;
this._holdTime = Math.max(0, timing.delay + timing.duration * timing.iterations);
this._playState = 'finished';
this._native('finish');
if (this._finishTimer != null) clearTimeout(this._finishTimer);
this._resolveFinished(this);
const event = new Event('finish');
this.dispatchEvent(event);
if (typeof this.onfinish === 'function') { try { this.onfinish.call(this, event); } catch (e) { console.error(e); } }
}
cancel() {
if (this._finishTimer != null) clearTimeout(this._finishTimer);
this._native('cancel');
this._registered = false;
this._playState = 'idle';
this._holdTime = 0;
this._startTime = null;
_waapiAnimations.delete(this);
this._rejectFinished(new DOMException('The animation was canceled', 'AbortError'));
const event = new Event('cancel');
this.dispatchEvent(event);
if (typeof this.oncancel === 'function') { try { this.oncancel.call(this, event); } catch (e) { console.error(e); } }
this._resetFinishedPromise();
}
reverse() { throw new DOMException('reverse() is not implemented for this animation', 'NotSupportedError'); }
addEventListener(type, callback, options) { _eventTargetAdd(this, type, callback, options); }
removeEventListener(type, callback, options) { _eventTargetRemove(this, type, callback, options); }
dispatchEvent(event) { return _eventTargetDispatch(this, event); }
}
class DocumentTimeline {
constructor(options = {}) {
this.originTime = Number(options.originTime) || 0;
}
get currentTime() { return performance.now() - this.originTime; }
}
function _animationsForTarget(target) {
return Array.from(_waapiAnimations).filter(animation => {
if (animation.effect?.target !== target || animation.playState === 'idle') return false;
return animation.playState !== 'finished' || animation.effect._timing.fill === 'forwards' || animation.effect._timing.fill === 'both';
});
}
class Element extends Node {
constructor(nid) {
const entry = _customElementConstructionStack[_customElementConstructionStack.length - 1];
@@ -3563,16 +3839,11 @@ class Element extends Node {
}, 0);
}
animate(keyframes, options) {
const duration = typeof options === 'number' ? options : (options?.duration || 0);
return {
finished: Promise.resolve(), currentTime: 0, playState: 'finished',
effect: { getComputedTiming() { return { duration }; } },
cancel(){}, finish(){}, play(){}, pause(){}, reverse(){},
addEventListener(){}, removeEventListener(){},
onfinish: null, oncancel: null,
};
const animation = new Animation(new KeyframeEffect(this, keyframes, options), document.timeline);
animation.play();
return animation;
}
getAnimations() { return []; }
getAnimations() { return _animationsForTarget(this); }
get isConnected() {
const root = this.getRootNode({ composed: true });
return !!root && root.nodeType === 9;
@@ -3816,6 +4087,16 @@ function _throwDocumentDomainSecurityError() {
}
class Document extends Node {
get timeline() {
if (!this._timeline) {
this._timeline = new DocumentTimeline();
}
return this._timeline;
}
getAnimations() {
return Array.from(_waapiAnimations).filter(animation => animation.playState !== 'idle'
&& (animation.playState !== 'finished' || animation.effect?._timing.fill === 'forwards' || animation.effect?._timing.fill === 'both'));
}
get documentElement() { return _wrapEl(+_dom("document_element")); }
get children() {
const root = this.documentElement;
@@ -6655,7 +6936,9 @@ globalThis.getComputedStyle = (el) => {
if (cacheable) _computedStyleSnapshotCache.set(el, snapshot);
}
const refreshRendered = () => {
if (snapshot.epoch === _domMutationEpoch) return;
const hasRunningAnimation = typeof _animationsForTarget === 'function'
&& _animationsForTarget(el).some(animation => animation.playState === 'running');
if (snapshot.epoch === _domMutationEpoch && !hasRunningAnimation) return;
snapshot.epoch = _domMutationEpoch;
snapshot.rendered = null;
if (typeof Deno.core.ops.op_computed_style === 'function' && el?._nid != null) {
@@ -9212,6 +9495,9 @@ globalThis.Document = Document;
// undefined (so `el.style instanceof CSSStyleDeclaration` threw). Assigning here
// only fills the value; the property stays enumerable:false, matching Chrome.
globalThis.CSSStyleDeclaration = CSSStyleDeclaration;
globalThis.Animation = Animation;
globalThis.KeyframeEffect = KeyframeEffect;
globalThis.DocumentTimeline = DocumentTimeline;
globalThis.XPathResult = globalThis.XPathResult || class XPathResult {};
Object.assign(globalThis.XPathResult, {
ANY_TYPE: 0,
+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() {
+225
View File
@@ -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#"
+119
View File
@@ -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) {
+3 -1
View File
@@ -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,