diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 951204b..febf1fd 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -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, diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index c4567ce..e739ebd 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -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, + duration: f32, + delay: f32, + iterations: f32, + fill: String, + direction: String, + easing_bezier: Option<[f32; 4]>, + linear_easing: Option>, +} + +#[cfg(feature = "render")] +#[derive(Deserialize)] +struct WaapiKeyframeInput { + offset: f32, + opacity: Option, + transform: Option, +} + +#[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::(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::().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::().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 { let document_url = url::Url::parse(&state.url).ok()?; diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index a57ea12..f0bb847 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -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#"
"#, + ); + 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::().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#"
"#); + 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() { diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index c0ab983..a15dd84 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -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::>(); + 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::>(); + 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 { + sample_waapi_track(track, underlying, progress, |from, to, position| { + from + (to - from) * position + }) +} + +fn sample_transform_waapi_track( + track: &[(f32, Vec)], + underlying: Vec, + progress: f32, +) -> Option> { + sample_waapi_track(track, underlying, progress, |from, to, position| { + interpolate_transform_list(from, to, position) + }) +} + +fn sample_waapi_track( + track: &[(f32, T)], + underlying: T, + progress: f32, + interpolate: impl Fn(&T, &T, f32) -> T, +) -> Option { + 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#"
"#); + 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#"
"#, + ); + 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#" diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 4068780..5b194b6 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -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, + pub transform: Option, +} + +#[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, + 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>, + pub start_time_ms: f32, + pub hold_time_ms: Option, + 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, start_candidates: std::collections::HashMap, subtree_start_candidates: std::collections::HashMap, + waapi: std::collections::BTreeMap, } 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 { + 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) { diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 132877d..82fd791 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -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, 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,