Make rendering asynchronous and timeline-aware

This commit is contained in:
SGavrl committed 2026-08-04 02:37:03 +00:00
1 parent 68f20cf525
commit 66f04d0f43
18 files changed
+3952 -290

No files matched your search

+4 -1
View File
@@ -9,7 +9,10 @@ pub use context::BrowserContext;
pub use lifecycle::{LifecycleState, WaitUntil};
pub use obscura_js::HTML_TO_MARKDOWN_JS;
#[cfg(feature = "render")]
pub use obscura_js::{validate_capture_region, CaptureError, CaptureRegion};
pub use obscura_js::{
validate_capture_region, AnimationSample, AnimationSampleMode, AnimationSampleTime,
CaptureError, CaptureRegion,
};
pub use page::{NetworkEvent, Page, PageError};
#[cfg(feature = "render")]
pub use pdf::{RasterPdfError, RasterPdfOptions, RasterPdfPageRange};
+327 -54
View File
@@ -233,6 +233,10 @@ pub struct Page {
/// Exposed to JS as `document.characterSet` and used for the URL query
/// encoding override on `<a>`/`<area>` hrefs in legacy-charset documents.
pub encoding: String,
/// Monotonic origin for the current document's CSS animation timeline.
/// It is reset once author styles are installed, so stylesheet download
/// latency does not incorrectly advance newly-created animations.
document_timeline_origin: std::time::Instant,
/// Navigation history for Page.getNavigationHistory / navigateToHistoryEntry.
/// Entries are URLs in visit order; `history_index` is the current position.
/// Pushed on every successful navigation; truncated on goBack -> new nav.
@@ -724,6 +728,7 @@ impl Page {
viewport: (1280.0, 720.0),
device_scale_factor: 1.0,
encoding: "UTF-8".to_string(),
document_timeline_origin: std::time::Instant::now(),
history: Vec::new(),
history_index: 0,
network_events: Vec::new(),
@@ -1852,6 +1857,11 @@ impl Page {
let mut idle_count = 0u32;
loop {
let now = tokio::time::Instant::now();
// `has_pending_dynamic_scripts` includes both inserted script
// elements and loader-owned ES-module graph activity. A lazy
// import may be the work which removes an SSR loading shell;
// do not take the 500ms fast exit while that graph is fetching.
// Unrelated fetch/XHR activity remains on the fast path.
if now >= deadline && (now >= dynamic_deadline || !js.has_pending_dynamic_scripts())
{
break;
@@ -2274,6 +2284,10 @@ impl Page {
}
}
}
self.document_timeline_origin = std::time::Instant::now();
if let Some(js) = &self.js {
js.reset_animation_timeline();
}
if let Some(js) = &mut self.js {
let _ = js.execute_script("<iframe-load>",
"(function() { var iframes = document.querySelectorAll('iframe[src]'); for (var i = 0; i < iframes.length; i++) { var src = iframes[i].getAttribute('src'); if (src && src !== 'about:blank') iframes[i]._loadIframeSrc(src); } })()");
@@ -2410,6 +2424,7 @@ impl Page {
));
self.title = String::new();
self.lifecycle = LifecycleState::Loaded;
self.document_timeline_origin = std::time::Instant::now();
}
pub fn url_string(&self) -> String {
@@ -2445,10 +2460,10 @@ impl Page {
let mut candidates = std::collections::BTreeMap::new();
if let Some(js) = &self.js {
for raw in js.pending_render_image_urls() {
for (raw, profile) in js.pending_render_image_urls() {
if let Ok(mut url) = url::Url::parse(&raw) {
url.set_fragment(None);
candidates.insert(url.to_string(), ResourceType::Image);
candidates.insert((url.to_string(), Some(profile)), ResourceType::Image);
}
}
let css_sources = js
@@ -2484,15 +2499,20 @@ impl Page {
.unwrap_or_default();
for css in css_sources {
for raw in css_resource_urls(&css, &base_url) {
if let Ok(url) = url::Url::parse(&raw) {
candidates.insert(raw, render_resource_type(&url));
if let Ok(mut url) = url::Url::parse(&raw) {
let kind = render_resource_type(&url);
url.set_fragment(None);
candidates.insert((url.to_string(), None), kind);
}
}
}
candidates.retain(|url, _| !js.render_resource_is_known(url));
candidates.retain(|(url, profile), _| match profile {
Some(profile) => !js.render_image_resource_is_known(url, *profile),
None => !js.render_resource_is_known(url),
});
}
candidates.retain(|url, _| {
candidates.retain(|(url, _), _| {
subresource_allowed(Some(&document_url), url) && !self.should_block_url(url)
});
if candidates.len() > 128 {
@@ -2502,14 +2522,18 @@ impl Page {
return 0;
}
let requested: Vec<(String, ResourceType)> = candidates.into_iter().collect();
let requested: Vec<(String, Option<obscura_js::ImageRequestProfile>, ResourceType)> =
candidates
.into_iter()
.map(|((url, profile), kind)| (url, profile, kind))
.collect();
let client = self.http_client.clone();
#[cfg(feature = "stealth")]
let stealth_client = self.stealth_client.clone();
let callbacks = self.callbacks.clone();
let initiator = document_url.clone();
use futures::StreamExt as _;
let requests = futures::stream::iter(requested.clone().into_iter().map(|(raw, kind)| {
let requests = futures::stream::iter(requested.into_iter().map(|(raw, profile, kind)| {
let client = client.clone();
#[cfg(feature = "stealth")]
let stealth_client = stealth_client.clone();
@@ -2517,7 +2541,18 @@ impl Page {
let initiator = initiator.clone();
async move {
let parsed = url::Url::parse(&raw).expect("validated render resource URL");
let request = ResourceRequest::subresource(kind, &initiator);
let mut request = ResourceRequest::subresource(kind, &initiator);
match profile {
Some(obscura_js::ImageRequestProfile::CorsSameOrigin) => {
request.mode = obscura_net::RequestMode::Cors;
request.credentials = obscura_net::RequestCredentials::SameOrigin;
}
Some(obscura_js::ImageRequestProfile::CorsInclude) => {
request.mode = obscura_net::RequestMode::Cors;
request.credentials = obscura_net::RequestCredentials::Include;
}
_ => {}
}
#[cfg(feature = "stealth")]
let result = if let Some(stealth_client) = stealth_client {
stealth_client
@@ -2532,57 +2567,54 @@ impl Page {
let result = client
.fetch_resource_with_callbacks(&parsed, request, Some(&callbacks))
.await;
(raw, kind, result)
(raw, profile, kind, result)
}
}))
.buffer_unordered(16);
futures::pin_mut!(requests);
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(max_ms);
let mut outcomes = std::collections::HashMap::new();
let mut loaded = 0usize;
loop {
match tokio::time::timeout_at(deadline, requests.next()).await {
Ok(Some((raw, kind, result))) => {
outcomes.insert(raw, (kind, result));
Ok(Some((raw, profile, kind, result))) => {
let outcome = match result {
Ok(response) => {
self.record_network_event_with_body(
response.url.as_str(),
"GET",
match kind {
ResourceType::Font => "Font",
_ => "Image",
},
response.status,
&response.headers,
&response.body,
true,
);
if (200..300).contains(&response.status) {
loaded += 1;
Some(response.body)
} else {
None
}
}
Err(_) => None,
};
if let Some(js) = &mut self.js {
match profile {
Some(profile) => {
js.seed_render_image_resource(raw, profile, outcome)
}
None => js.seed_render_resource(raw, outcome),
}
}
}
Ok(None) | Err(_) => break,
}
}
// A deadline drops unfinished futures without negative-caching them,
// so a later warmup can retry slow resources.
drop(requests);
let mut loaded = 0usize;
for (raw, kind) in requested {
let outcome = match outcomes.remove(&raw) {
Some((_, Ok(response))) => {
self.record_network_event_with_body(
response.url.as_str(),
"GET",
match kind {
ResourceType::Font => "Font",
_ => "Image",
},
response.status,
&response.headers,
&response.body,
true,
);
if (200..300).contains(&response.status) {
loaded += 1;
Some(response.body)
} else {
None
}
}
Some((_, Err(_))) => None,
// The shared deadline cancelled this request before it had a
// transport outcome. Keep it unknown so the final capture can
// retry instead of suppressing a valid slow image/font behind
// the renderer's negative-cache TTL.
None => continue,
};
if let Some(js) = &mut self.js {
js.seed_render_resource(raw, outcome);
}
}
tracing::debug!(
loaded,
elapsed_ms = started.elapsed().as_millis(),
@@ -2596,11 +2628,41 @@ impl Page {
/// viewport is zero-sized.
#[cfg(feature = "render")]
pub fn screenshot(&self, viewport: (f32, f32)) -> Option<Vec<u8>> {
self.screenshot_with_animation_sample(viewport, self.live_animation_sample())
}
/// Rasterize every CSS animation at one explicit local time. This mirrors
/// Web Animations `currentTime` and is intended for deterministic parity
/// capture; ordinary screenshots use each live instance's start epoch.
#[cfg(feature = "render")]
pub fn screenshot_at_animation_time(
&self,
viewport: (f32, f32),
animation_sample_time: obscura_js::AnimationSampleTime,
) -> Option<Vec<u8>> {
self.screenshot_with_animation_sample(
viewport,
obscura_js::AnimationSample {
time: animation_sample_time,
mode: obscura_js::AnimationSampleMode::LocalOverride,
},
)
}
#[cfg(feature = "render")]
pub fn screenshot_with_animation_sample(
&self,
viewport: (f32, f32),
animation_sample: obscura_js::AnimationSample,
) -> Option<Vec<u8>> {
// Needed to resolve the relative image URLs ("logo.svg") that make up
// the overwhelming majority of real markup.
let base_url = self.resolve_base_url();
let base_url = base_url.as_ref().map(|u| u.as_str());
if let Some(js) = &self.js {
if !js.set_animation_sample(animation_sample) {
return None;
}
if let Some(png) = js.screenshot_prepared(viewport, base_url) {
return Some(png);
}
@@ -2612,7 +2674,15 @@ impl Page {
.as_ref()
.map(|js| js.scroll_offset())
.unwrap_or((0.0, 0.0));
self.with_dom(|dom| obscura_js::screenshot_png_scrolled(dom, viewport, base_url, scroll))
self.with_dom(|dom| {
obscura_js::screenshot_png_scrolled_at_animation_time(
dom,
viewport,
base_url,
scroll,
animation_sample.time,
)
})
.flatten()
}
@@ -2624,10 +2694,38 @@ impl Page {
&self,
region: obscura_js::CaptureRegion,
) -> Result<Vec<u8>, obscura_js::CaptureError> {
self.js
self.screenshot_region_with_animation_sample(region, self.live_animation_sample())
}
#[cfg(feature = "render")]
pub fn screenshot_region_at_animation_time(
&self,
region: obscura_js::CaptureRegion,
animation_sample_time: obscura_js::AnimationSampleTime,
) -> Result<Vec<u8>, obscura_js::CaptureError> {
self.screenshot_region_with_animation_sample(
region,
obscura_js::AnimationSample {
time: animation_sample_time,
mode: obscura_js::AnimationSampleMode::LocalOverride,
},
)
}
#[cfg(feature = "render")]
pub fn screenshot_region_with_animation_sample(
&self,
region: obscura_js::CaptureRegion,
animation_sample: obscura_js::AnimationSample,
) -> Result<Vec<u8>, obscura_js::CaptureError> {
let js = self
.js
.as_ref()
.ok_or(obscura_js::CaptureError::PaintFailed)?
.screenshot_prepared_region(region)
.ok_or(obscura_js::CaptureError::PaintFailed)?;
if !js.set_animation_sample(animation_sample) {
return Err(obscura_js::CaptureError::PaintFailed);
}
js.screenshot_prepared_region(region)
}
/// Scrollable document dimensions from the retained render layout. Unlike
@@ -2635,7 +2733,50 @@ impl Page {
/// monkey-patched by the document being captured.
#[cfg(feature = "render")]
pub fn prepared_content_size(&self) -> Option<(f32, f32)> {
self.js.as_ref()?.prepared_content_size()
self.prepared_content_size_with_animation_sample(self.live_animation_sample())
}
#[cfg(feature = "render")]
pub fn prepared_content_size_at_animation_time(
&self,
animation_sample_time: obscura_js::AnimationSampleTime,
) -> Option<(f32, f32)> {
self.prepared_content_size_with_animation_sample(obscura_js::AnimationSample {
time: animation_sample_time,
mode: obscura_js::AnimationSampleMode::LocalOverride,
})
}
#[cfg(feature = "render")]
pub fn prepared_content_size_with_animation_sample(
&self,
animation_sample: obscura_js::AnimationSample,
) -> Option<(f32, f32)> {
let js = self.js.as_ref()?;
js.set_animation_sample(animation_sample)
.then(|| js.prepared_content_size())
.flatten()
}
#[cfg(feature = "render")]
pub fn live_animation_sample(&self) -> obscura_js::AnimationSample {
if let Some(js) = &self.js {
return js.live_animation_sample();
}
let milliseconds = self.document_timeline_origin.elapsed().as_secs_f64() * 1_000.0;
obscura_js::AnimationSample {
time: obscura_js::AnimationSampleTime {
milliseconds: milliseconds.min(f64::from(f32::MAX)) as f32,
},
mode: obscura_js::AnimationSampleMode::DocumentTime,
}
}
#[cfg(feature = "render")]
pub fn prepared_has_active_css_animations(&self) -> bool {
self.js
.as_ref()
.is_some_and(|js| js.prepared_has_active_css_animations())
}
/// Renderer-owned root scroll offset for document-space capture routing.
@@ -4026,6 +4167,128 @@ mod tests {
);
}
#[tokio::test(flavor = "current_thread")]
async fn lazy_module_graph_keeps_post_script_settle_alive() {
use std::io::{Read as _, Write as _};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
std::thread::spawn(move || {
for _ in 0..2 {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0u8; 2048];
let length = stream.read(&mut request).unwrap();
let request_text = String::from_utf8_lossy(&request[..length]);
let path = request_text
.lines()
.next()
.and_then(|line| line.split_ascii_whitespace().nth(1))
.unwrap_or("/");
let body = match path {
"/app/lazy.js" => {
"import { ready } from './lazy-child.js'; export { ready };"
}
"/app/lazy-child.js" => {
// Cross the lifecycle's 500ms fast-settle floor on a
// descendant edge. deno_core must propagate the lazy
// graph marker beyond its root for this to stay alive.
std::thread::sleep(std::time::Duration::from_millis(700));
"export const ready = 'lazy-ready';"
}
unexpected => panic!("unexpected module request: {unexpected}"),
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len(),
);
stream.write_all(response.as_bytes()).unwrap();
}
});
let base = format!("http://{address}");
let mut page = import_map_test_page(
"lazy-module-readiness",
&base,
r#"<html><body><script>
import("./lazy.js").then(module => {
document.body.setAttribute("data-lazy-state", module.ready);
});
</script></body></html>"#,
);
page.execute_scripts().await;
assert_eq!(
page.js
.as_mut()
.unwrap()
.evaluate("document.body.getAttribute('data-lazy-state')")
.unwrap(),
serde_json::json!("lazy-ready"),
"post-script readiness must include an in-flight lazy module graph",
);
}
#[tokio::test(flavor = "current_thread")]
async fn ordinary_fetch_does_not_extend_dynamic_module_settle() {
use std::io::{Read as _, Write as _};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let (accepted_tx, accepted_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let _ = accepted_tx.send(());
let mut request = [0u8; 2048];
let length = stream.read(&mut request).unwrap();
assert!(String::from_utf8_lossy(&request[..length])
.starts_with("GET /app/analytics "));
std::thread::sleep(std::time::Duration::from_secs(2));
let body = "{}";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len(),
);
let _ = stream.write_all(response.as_bytes());
});
let base = format!("http://{address}");
let html = format!(
r#"<html><body><script>
globalThis.__analyticsStarted = true;
fetch("{base}/app/analytics").catch(error => {{
globalThis.__analyticsError = error.message;
}});
</script></body></html>"#,
);
let mut page = import_map_test_page(
"ordinary-fetch-readiness",
&base,
&html,
);
let started = std::time::Instant::now();
page.execute_scripts().await;
let elapsed = started.elapsed();
assert!(
accepted_rx
.recv_timeout(std::time::Duration::from_millis(100))
.is_ok(),
"ordinary fetch fixture must actually start its network request",
);
assert!(
elapsed < std::time::Duration::from_millis(1_500),
"ordinary fetch/XHR must retain the fast settle path; elapsed={elapsed:?}",
);
assert_eq!(
page.js
.as_mut()
.unwrap()
.evaluate("globalThis.__analyticsStarted")
.unwrap(),
serde_json::json!(true),
);
}
#[tokio::test(flavor = "current_thread")]
async fn dynamic_import_map_uses_live_document_base_at_insertion() {
let (base, requests) = spawn_parser_import_map_server(1);
@@ -4142,7 +4405,8 @@ mod tests {
let mut page = super::Page::new("render-prefetch".to_string(), context);
page.set_viewport((100.0, 80.0));
let page_url = format!("http://{address}/page");
let asset_url = format!("http://{address}/asset.svg");
let asset_network_url = format!("http://{address}/asset.svg");
let asset_url = format!("{asset_network_url}#icon");
let dom = parse_html(&format!(
r#"<html><body><img src="{asset_url}" style="width:20px;height:10px"></body></html>"#
));
@@ -4155,6 +4419,15 @@ mod tests {
page.url = Some(url::Url::parse(&page_url).unwrap());
assert_eq!(page.prepare_screenshot_resources(1_000).await, 1);
assert_eq!(
page.js
.as_mut()
.unwrap()
.evaluate("document.querySelector('img').currentSrc")
.unwrap(),
serde_json::json!(asset_url),
"cache/network fragment normalization must not alter currentSrc"
);
page.screenshot(page.viewport).expect("prefetched capture");
assert!(seen_rx
.recv_timeout(std::time::Duration::from_secs(1))
+25
View File
@@ -278,12 +278,37 @@ impl Page {
/// vertical slices become pages. This does not reflow into `@media print`
/// or implement CSS paged media, headers, or footers.
pub fn raster_pdf(&self, options: RasterPdfOptions) -> Result<Vec<u8>, RasterPdfError> {
self.raster_pdf_with_animation_sample(options, self.live_animation_sample())
}
pub fn raster_pdf_at_animation_time(
&self,
options: RasterPdfOptions,
animation_sample_time: obscura_js::AnimationSampleTime,
) -> Result<Vec<u8>, RasterPdfError> {
self.raster_pdf_with_animation_sample(
options,
obscura_js::AnimationSample {
time: animation_sample_time,
mode: obscura_js::AnimationSampleMode::LocalOverride,
},
)
}
pub fn raster_pdf_with_animation_sample(
&self,
options: RasterPdfOptions,
animation_sample: obscura_js::AnimationSample,
) -> Result<Vec<u8>, RasterPdfError> {
let (page_width, page_height, printable_width, printable_height, left, bottom) =
options.page_geometry()?;
let js = self
.js
.as_ref()
.ok_or(RasterPdfError::NoRenderableDocument)?;
if !js.set_animation_sample(animation_sample) {
return Err(RasterPdfError::NoRenderableDocument);
}
let (content_width, content_height) = js
.prepared_content_size()
.ok_or(RasterPdfError::NoRenderableDocument)?;
+134 -14
View File
@@ -425,6 +425,7 @@ pub(crate) fn queue_screencast_frame(
let page = ctx
.get_session_page_mut(&attached_session)
.ok_or("No page for session")?;
let animation_sample = page.live_animation_sample();
let viewport = page.viewport;
let scroll = page
.evaluate("[window.scrollX, window.scrollY]")
@@ -444,9 +445,12 @@ pub(crate) fn queue_screencast_frame(
1.0,
))
.map_err(capture_error_message)?;
let png = page.screenshot(viewport).ok_or_else(|| {
"Page.startScreencast failed: the page has no visible DOM surface to render".to_string()
})?;
let png = page
.screenshot_with_animation_sample(viewport, animation_sample)
.ok_or_else(|| {
"Page.startScreencast failed: the page has no visible DOM surface to render"
.to_string()
})?;
let activity_generation = page
.js
.as_ref()
@@ -501,7 +505,7 @@ pub(crate) async fn pump_screencast_frames(ctx: &mut CdpContext) {
continue;
}
let attached_session = Some(cdp_session_id.clone());
let activity_generation = {
let (activity_generation, css_animation_active) = {
let Some(page) = ctx.get_session_page_mut(&attached_session) else {
continue;
};
@@ -511,14 +515,16 @@ pub(crate) async fn pump_screencast_frames(ctx: &mut CdpContext) {
// A V8 watchdog bounds synchronous callbacks as well as the async
// timeout, so a hostile timer cannot freeze the CDP connection.
let _ = js.run_event_loop_bounded(EVENT_LOOP_SLICE_MS).await;
js.activity_generation()
let generation = js.activity_generation();
let css_animation_active = page.prepared_has_active_css_animations();
(generation, css_animation_active)
};
let should_attempt = {
let Some(state) = ctx.screencasts.get_mut(&cdp_session_id) else {
continue;
};
if state.observed_activity_generation != activity_generation {
if state.observed_activity_generation != activity_generation || css_animation_active {
state.observed_activity_generation = activity_generation;
state.autonomous_frame_pending = true;
}
@@ -1253,6 +1259,7 @@ pub async fn handle(
.get_session_page_mut(session_id)
.ok_or("No page for session")?;
prepare_capture_resources_if_requested(page).await;
let animation_sample = page.live_animation_sample();
let viewport = page.viewport;
let device_scale_factor = f64::from(page.device_scale_factor);
let trusted_scroll = page.screenshot_scroll_offset();
@@ -1275,9 +1282,11 @@ pub async fn handle(
}
Some(chromium_clip_region(clip, device_scale_factor)?)
} else if options.capture_beyond_viewport {
let content_size = page.prepared_content_size().ok_or_else(|| {
"Page.captureScreenshot failed: no retained document size".to_string()
})?;
let content_size = page
.prepared_content_size_with_animation_sample(animation_sample)
.ok_or_else(|| {
"Page.captureScreenshot failed: no retained document size".to_string()
})?;
Some(obscura_browser::CaptureRegion::new(
0.0,
0.0,
@@ -1299,7 +1308,7 @@ pub async fn handle(
let png = match region {
Some(region) => page
.screenshot_region(region)
.screenshot_region_with_animation_sample(region, animation_sample)
.map_err(capture_error_message)?,
None => {
obscura_browser::validate_capture_region(
@@ -1312,10 +1321,11 @@ pub async fn handle(
),
)
.map_err(capture_error_message)?;
page.screenshot(viewport).ok_or_else(|| {
"Page.captureScreenshot failed: the page has no DOM to render"
.to_string()
})?
page.screenshot_with_animation_sample(viewport, animation_sample)
.ok_or_else(|| {
"Page.captureScreenshot failed: the page has no DOM to render"
.to_string()
})?
}
};
@@ -1535,6 +1545,116 @@ mod tests {
assert_eq!(frame.params["sessionId"], 1);
}
#[cfg(feature = "render")]
#[tokio::test]
async fn css_animation_drives_autonomous_screencast_frames_until_completion() {
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = format!("{page_id}-session");
ctx.sessions.insert(session_id.clone(), page_id);
let session = Some(session_id);
ctx.get_session_page_mut(&session)
.expect("page")
.set_viewport((80.0, 60.0));
handle(
"navigate",
&json!({
"url": "data:text/html,<html style='margin:0'><head><style>@keyframes hide{from{opacity:1}to{opacity:0}}#cover{width:80px;height:60px;background:red;animation:hide 100ms linear forwards}</style></head><body style='margin:0;background:lime'><div id=cover></div></body></html>",
"waitUntil": "load",
}),
&mut ctx,
&session,
)
.await
.expect("navigate animation fixture");
ctx.pending_events.clear();
handle("startScreencast", &json!({}), &mut ctx, &session)
.await
.expect("start screencast");
let initial = ctx
.pending_events
.iter()
.find(|event| event.method == "Page.screencastFrame")
.expect("initial frame")
.params["data"]
.as_str()
.unwrap()
.to_string();
let generation = ctx
.get_session_page_mut(&session)
.unwrap()
.js
.as_ref()
.unwrap()
.activity_generation();
ctx.pending_events.clear();
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
pump_screencast_frames(&mut ctx).await;
let (animated, frame_session_id) = {
let event = ctx
.pending_events
.iter()
.find(|event| event.method == "Page.screencastFrame")
.expect("CSS-only animation frame");
(
event.params["data"].as_str().unwrap().to_string(),
event.params["sessionId"].as_u64().unwrap(),
)
};
assert_ne!(animated, initial);
assert_eq!(
ctx.get_session_page_mut(&session)
.unwrap()
.js
.as_ref()
.unwrap()
.activity_generation(),
generation,
"CSS timeline damage must not depend on DOM or task activity"
);
for _ in 0..2 {
handle(
"screencastFrameAck",
&json!({"sessionId": frame_session_id}),
&mut ctx,
&session,
)
.await
.expect("ack frame");
}
ctx.pending_events.clear();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
pump_screencast_frames(&mut ctx).await;
assert!(
ctx.pending_events
.iter()
.any(|event| event.method == "Page.screencastFrame"),
"the stream must emit the finite animation's final frame"
);
assert!(!ctx
.get_session_page_mut(&session)
.unwrap()
.prepared_has_active_css_animations());
handle(
"screencastFrameAck",
&json!({"sessionId": frame_session_id}),
&mut ctx,
&session,
)
.await
.expect("ack final frame");
ctx.pending_events.clear();
pump_screencast_frames(&mut ctx).await;
assert!(
ctx.pending_events.is_empty(),
"a completed finite animation must not keep rasterizing idle frames"
);
}
#[cfg(feature = "render")]
#[tokio::test]
async fn screencast_sampling_backpressure_and_stale_acks_are_bounded() {
+2 -1
View File
@@ -226,8 +226,9 @@ pub async fn print_to_pdf(
.get_session_page_mut(session_id)
.ok_or("No page for session")?;
crate::domains::page::prepare_capture_resources_if_requested(page).await;
let animation_sample = page.live_animation_sample();
let pdf = page
.raster_pdf(options.raster)
.raster_pdf_with_animation_sample(options.raster, animation_sample)
.map_err(|error| error.to_string())?;
let mut response = json!({
"obscuraPrintMode": "screen-raster",
+25 -2
View File
@@ -902,6 +902,29 @@ async fn run_fetch(
// OBSCURA_SHOT_W / OBSCURA_SHOT_H override it (e.g. a tall viewport to
// capture below-the-fold content in one shot).
let viewport = screenshot_viewport.unwrap_or((1280.0, 720.0));
// Ordinary screenshots sample the live document timeline. The
// comparison harness can request an exact instant (normally T=0)
// so both engines paint the same animation frame.
let requested_animation_sample = std::env::var("OBSCURA_SHOT_ANIMATION_TIME_MS")
.ok()
.map(|raw| {
let milliseconds = raw.parse::<f32>().map_err(|_| {
anyhow::anyhow!(
"OBSCURA_SHOT_ANIMATION_TIME_MS must be a finite non-negative number"
)
})?;
if !milliseconds.is_finite() || milliseconds < 0.0 {
anyhow::bail!(
"OBSCURA_SHOT_ANIMATION_TIME_MS must be a finite non-negative number"
);
}
Ok(obscura_browser::AnimationSampleTime { milliseconds })
})
.transpose()?;
let capture_screenshot = |page: &Page| match requested_animation_sample {
Some(sample) => page.screenshot_at_animation_time(viewport, sample),
None => page.screenshot(viewport),
};
// The parity harness performs one throwaway paint in both engines
// before observing image/font readiness. Obscura resolves retained
// render resources during prepare/paint, so sampling first would
@@ -910,7 +933,7 @@ async fn run_fetch(
let warmup_capture =
std::env::var("OBSCURA_SHOT_RESOURCE_WARMUP").is_ok_and(|value| value == "1");
if warmup_capture {
if page.screenshot(viewport).is_none() {
if capture_screenshot(&page).is_none() {
anyhow::bail!("resource warm-up screenshot failed: page has no DOM to render");
}
// Give completion callbacks one bounded task turn before the
@@ -962,7 +985,7 @@ async fn run_fetch(
}))()",
)
});
match page.screenshot(viewport) {
match capture_screenshot(&page) {
Some(bytes) => std::fs::write(path, &bytes)?,
None => anyhow::bail!("screenshot failed: page has no DOM to render"),
}
@@ -72,6 +72,57 @@ fn screenshot_is_captured_after_eval_scrolls_the_live_page() {
std::fs::remove_dir_all(directory).expect("remove temporary output directory");
}
#[test]
fn screenshots_use_live_animation_time_unless_harness_pins_a_sample() {
let directory = output_directory();
std::fs::create_dir_all(&directory).expect("temporary output directory");
let live = directory.join("live.png");
let initial = directory.join("initial.png");
let url = concat!(
"data:text/html,<html style=\"margin:0\"><head><style>",
"@keyframes hide{from{opacity:1}to{opacity:0}}",
"#cover{width:80px;height:60px;background:red;",
"animation:hide 50ms linear forwards}",
"</style></head><body style=\"margin:0;background:lime\">",
"<div id=\"cover\"></div></body></html>"
);
let run = |path: &std::path::Path, animation_time: Option<&str>| {
let mut command = Command::new(env!("CARGO_BIN_EXE_obscura"));
command
.args(["fetch", url, "--screenshot"])
.arg(path)
.args(["--wait", "1", "--timeout", "5", "--quiet"])
.env("OBSCURA_SHOT_W", "80")
.env("OBSCURA_SHOT_H", "60")
.env_remove("OBSCURA_SHOT_ANIMATION_TIME_MS");
if let Some(milliseconds) = animation_time {
command.env("OBSCURA_SHOT_ANIMATION_TIME_MS", milliseconds);
}
command.output().expect("run obscura fetch")
};
let live_output = run(&live, None);
assert!(
live_output.status.success(),
"live capture failed: {}",
String::from_utf8_lossy(&live_output.stderr)
);
let initial_output = run(&initial, Some("0"));
assert!(
initial_output.status.success(),
"explicit capture failed: {}",
String::from_utf8_lossy(&initial_output.stderr)
);
assert_ne!(
std::fs::read(live).expect("live PNG"),
std::fs::read(initial).expect("initial PNG"),
"live capture must advance past a finite overlay while explicit T=0 preserves it"
);
std::fs::remove_dir_all(directory).expect("remove temporary output directory");
}
#[test]
fn paired_capture_reasserts_scroll_after_the_post_eval_settle() {
let directory = output_directory();
+35 -11
View File
@@ -647,7 +647,13 @@ const _scheduleAfter = (delay, fn) => {
}
// The callback runs only when the embedder pumps the event loop, after the
// current microtask checkpoint.
return Deno.core.queueUserTimer(0, false, d, fn);
return Deno.core.queueUserTimer(0, false, d, () => {
// HTML timer/observer/rAF delivery starts a new task. Freeze animation
// time lazily on that task's first style/layout read so a callback that
// waited in the host queue samples its actual delivery instant.
Deno.core.ops.op_begin_render_task?.();
return fn();
});
};
// Timers accept a string first arg per the HTML spec (e.g. the Aliyun WAF
@@ -4530,7 +4536,8 @@ class HTMLImageElement extends Element {
setAttribute(name, value) {
const normalized = String(name).toLowerCase();
super.setAttribute(name, value);
if (normalized === "src" || normalized === "srcset" || normalized === "sizes") {
if (normalized === "src" || normalized === "srcset" || normalized === "sizes"
|| normalized === "crossorigin") {
this._imageSourceChanged();
}
else if ((normalized === "onload" || normalized === "onerror")
@@ -4540,7 +4547,8 @@ class HTMLImageElement extends Element {
removeAttribute(name) {
const normalized = String(name).toLowerCase();
super.removeAttribute(name);
if (normalized === "src" || normalized === "srcset" || normalized === "sizes") {
if (normalized === "src" || normalized === "srcset" || normalized === "sizes"
|| normalized === "crossorigin") {
this._imageSourceChanged();
}
}
@@ -4581,30 +4589,46 @@ class HTMLImageElement extends Element {
this._imageQueued = true;
const request = this._imageRequest;
setTimeout(() => {
this._imageQueued = false;
if (request === this._imageRequest && !this._imageComplete) {
this._runImageRequest(request);
} else if (request === this._imageRequest) {
this._imageQueued = false;
}
}, 1);
}
_runImageRequest(request) {
let metadata = null;
const finish = (metadata) => {
if (request !== this._imageRequest) return;
this._imageQueued = false;
if (metadata && metadata.state === "stale") {
this._refreshImageFromCache(true);
this._queueImageRequest();
return;
}
this._applyImageMetadata(metadata, request, true);
};
try {
const op = Deno.core.ops.op_image_metadata;
const op = Deno.core.ops.op_load_image_metadata;
if (typeof op === "function") {
metadata = JSON.parse(op(this._nid >>> 0, false));
Promise.resolve(op(this._nid >>> 0)).then(
raw => {
let metadata = null;
try { metadata = JSON.parse(raw); }
catch (_error) { metadata = { ok: false, currentSrc: this.src }; }
finish(metadata);
},
() => finish({ ok: false, currentSrc: this.src }),
);
} else {
// Non-render builds have no authoritative resource cache. Preserve the
// old non-blocking compatibility behavior without issuing a duplicate
// network fetch: the request succeeds with unknown intrinsic size.
metadata = { ok: true, currentSrc: this.src, width: 0, height: 0 };
finish({ ok: true, currentSrc: this.src, width: 0, height: 0 });
}
} catch (_error) {
metadata = { ok: false, currentSrc: this.src };
finish({ ok: false, currentSrc: this.src });
}
if (request !== this._imageRequest) return;
this._applyImageMetadata(metadata, request, true);
}
_refreshImageFromCache(deferCompletion) {
+3 -1
View File
@@ -13,6 +13,8 @@ pub use v8_flags::set_v8_flags;
// render feature (which enables obscura-render/paint) is compiled in.
#[cfg(feature = "render")]
pub use obscura_render::{
screenshot_png, screenshot_png_scrolled, validate_capture_region, CaptureError, CaptureRegion,
screenshot_png, screenshot_png_scrolled, screenshot_png_scrolled_at_animation_time,
validate_capture_region, AnimationSample, AnimationSampleMode, AnimationSampleTime,
CaptureError, CaptureRegion, ImageRequestProfile,
MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
};
+70 -1
View File
@@ -14,6 +14,58 @@ use deno_core::RequestedModuleType;
use crate::import_map::ImportMap;
use crate::ops::ObscuraState;
/// Observable network activity for ES-module graphs.
///
/// deno_core keeps dynamic-import state inside its private module map. The
/// browser lifecycle still needs to distinguish a genuinely idle page from a
/// graph whose fetch future is being advanced in short event-loop slices. A
/// loader-owned counter provides that signal without treating unrelated
/// fetch/XHR analytics as render-blocking work.
#[derive(Debug, Default)]
pub(crate) struct ModuleLoadActivity {
pending: std::sync::atomic::AtomicUsize,
last_activity: std::sync::Mutex<Option<std::time::Instant>>,
}
impl ModuleLoadActivity {
fn begin(self: &Arc<Self>) -> ModuleLoadGuard {
self.pending
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
*self
.last_activity
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(std::time::Instant::now());
ModuleLoadGuard(self.clone())
}
pub(crate) fn is_pending_or_recent(&self, grace: std::time::Duration) -> bool {
if self.pending.load(std::sync::atomic::Ordering::Relaxed) != 0 {
return true;
}
self.last_activity
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.is_some_and(|last| last.elapsed() <= grace)
}
}
struct ModuleLoadGuard(Arc<ModuleLoadActivity>);
impl Drop for ModuleLoadGuard {
fn drop(&mut self) {
let previous = self
.0
.pending
.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
debug_assert!(previous > 0, "module load activity counter underflow");
*self
.0
.last_activity
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(std::time::Instant::now());
}
}
pub struct ObscuraModuleLoader {
pub base_url: String,
/// Proxy URL threaded through to every dynamic ES-module fetch (#139).
@@ -29,6 +81,7 @@ pub struct ObscuraModuleLoader {
/// connection pool; they simply have an isolated cookie jar.
standalone_client: Option<Arc<obscura_net::ObscuraHttpClient>>,
import_map: Rc<RefCell<ImportMap>>,
activity: Arc<ModuleLoadActivity>,
}
impl ObscuraModuleLoader {
@@ -56,6 +109,7 @@ impl ObscuraModuleLoader {
page_state: None,
standalone_client: Some(standalone_client),
import_map,
activity: Arc::new(ModuleLoadActivity::default()),
}
}
@@ -71,8 +125,13 @@ impl ObscuraModuleLoader {
page_state: Some(Rc::downgrade(page_state)),
standalone_client: None,
import_map,
activity: Arc::new(ModuleLoadActivity::default()),
}
}
pub(crate) fn activity(&self) -> Arc<ModuleLoadActivity> {
self.activity.clone()
}
}
fn io_err(msg: String) -> ModuleLoaderError {
@@ -117,13 +176,19 @@ impl ModuleLoader for ObscuraModuleLoader {
&self,
module_specifier: &ModuleSpecifier,
_maybe_referrer: Option<&ModuleSpecifier>,
_is_dyn_import: bool,
is_dyn_import: bool,
_requested_module_type: RequestedModuleType,
) -> ModuleLoadResponse {
let url = module_specifier.to_string();
// Capture the loader's proxy here so the async closure below owns a
// plain Option<String> rather than borrowing &self across an `await`.
let proxy_url = self.proxy_url.clone();
let activity = self.activity.clone();
// Register before returning the future. The lifecycle can inspect the
// runtime between deno_core accepting the load and first polling it.
// Keeping the guard inside the future makes cancellation/navigation
// decrement the count through Drop as well as success and failure.
let activity_guard = is_dyn_import.then(|| activity.begin());
let page_network = match self.page_state.as_ref() {
Some(weak) => (|| {
let state = weak
@@ -146,6 +211,10 @@ impl ModuleLoader for ObscuraModuleLoader {
};
ModuleLoadResponse::Async(Pin::from(Box::new(async move {
// deno_core propagates `is_dyn_import` to every dependency edge in
// the recursive graph, so this excludes parser-discovered/static
// graphs without losing descendant fetches of a lazy graph.
let _activity_guard = activity_guard;
tracing::debug!(
"Loading ES module: {} (proxy: {})",
url,
+589 -127
View File
@@ -8,6 +8,8 @@ use deno_core::op2;
use deno_core::Extension;
use deno_core::OpState;
use obscura_dom::{DomTree, NodeData, NodeId};
#[cfg(feature = "render")]
use obscura_net::{RequestCredentials, RequestMode, ResourceRequest};
#[cfg(feature = "stealth")]
use obscura_net::StealthHttpClient;
use obscura_net::{
@@ -74,6 +76,9 @@ pub struct JsNetworkEvent {
pub timestamp: f64,
}
#[cfg(feature = "render")]
pub use obscura_render::ImageRequestProfile;
pub struct ObscuraState {
pub dom: Option<DomTree>,
pub url: String,
@@ -125,10 +130,28 @@ pub struct ObscuraState {
/// The browser settle policy samples this to distinguish useful deferred
/// rendering work from unrelated long-lived timers.
pub activity_generation: u64,
/// Monotonic identity of the currently installed document. Async resource
/// completions use this to discard bytes and lifecycle results belonging
/// to a navigation that has already been replaced.
pub document_generation: u64,
/// Final image/font-aware layout shared by CSSOM geometry and screenshots.
/// DOM/style/viewport changes clear this value but retain resource bytes.
#[cfg(feature = "render")]
pub prepared_render: Option<obscura_render::PreparedRender>,
/// Explicit document-timeline sample used by the next style/layout flush.
/// Captures set this to either deterministic T=0 or live document time.
#[cfg(feature = "render")]
pub animation_sample: obscura_render::AnimationSample,
#[cfg(feature = "render")]
pub animation_timeline: obscura_render::AnimationTimelineState,
#[cfg(feature = "render")]
pub animation_timeline_origin: std::time::Instant,
/// Host/HTML task epoch for document-timeline sampling. Geometry and
/// computed-style reads within one task share one frozen animation frame.
#[cfg(feature = "render")]
pub animation_task_generation: u64,
#[cfg(feature = "render")]
pub animation_sampled_task_generation: u64,
/// Connected mutations awaiting dependency-indexed retained style refresh.
/// Tree changes carry stable node/parent ids so a later geometry read can
/// coalesce framework DOM churn into one conservative local cascade.
@@ -138,6 +161,12 @@ pub struct ObscuraState {
/// relayout of the same document reuses it without refetching.
#[cfg(feature = "render")]
pub render_resources: obscura_render::RenderResourceCache,
/// Waiters sharing an asynchronous HTMLImageElement request. The key keeps
/// navigation identity and request credentials separate so neither stale
/// pages nor incompatible CORS profiles share a completion.
#[cfg(feature = "render")]
pub render_image_in_flight:
HashMap<(u64, String, ImageRequestProfile), Vec<tokio::sync::oneshot::Sender<()>>>,
/// One exact-key compiled author stylesheet for this document. Connected
/// mutations still discard `prepared_render`; the next prepare reuses only
/// parsing/indexing when ordered CSS source and viewport remain identical.
@@ -199,13 +228,26 @@ impl ObscuraState {
js_network_events: Vec::new(),
page_in_flight: Arc::new(std::sync::atomic::AtomicU32::new(0)),
activity_generation: 0,
document_generation: 0,
#[cfg(feature = "render")]
prepared_render: None,
#[cfg(feature = "render")]
animation_sample: obscura_render::AnimationSample::default(),
#[cfg(feature = "render")]
animation_timeline: obscura_render::AnimationTimelineState::default(),
#[cfg(feature = "render")]
animation_timeline_origin: std::time::Instant::now(),
#[cfg(feature = "render")]
animation_task_generation: 0,
#[cfg(feature = "render")]
animation_sampled_task_generation: 0,
#[cfg(feature = "render")]
pending_style_mutations: Vec::new(),
#[cfg(feature = "render")]
render_resources: obscura_render::RenderResourceCache::default(),
#[cfg(feature = "render")]
render_image_in_flight: HashMap::new(),
#[cfg(feature = "render")]
stylesheet_cache: obscura_render::StylesheetCache::default(),
#[cfg(feature = "render")]
dynamic_fonts: Vec::new(),
@@ -480,18 +522,12 @@ fn retained_style_mutation(
arg2: &str,
) -> Option<obscura_render::RetainedStyleMutation> {
let node = NodeId::new(arg1.parse::<u32>().ok()?);
let can_retain = |name: &str| {
let name = name.to_ascii_lowercase();
name == "class"
|| name == "id"
|| name == "style"
|| name.starts_with("data-")
|| name.starts_with("aria-")
};
match cmd {
"set_attribute" => {
let (name, value) = arg2.split_once('\0')?;
if !can_retain(name) {
if obscura_render::dom::retained_attribute_mutation_kind(dom, node, name)
== obscura_render::dom::RetainedAttributeMutationKind::Full
{
return None;
}
let keeps_selector_value = !name.eq_ignore_ascii_case("style");
@@ -510,9 +546,14 @@ fn retained_style_mutation(
}
.into())
}
"remove_attribute" => can_retain(arg2).then(|| {
"remove_attribute" => {
if obscura_render::dom::retained_attribute_mutation_kind(dom, node, arg2)
== obscura_render::dom::RetainedAttributeMutationKind::Full
{
return None;
}
let keeps_selector_value = !arg2.eq_ignore_ascii_case("style");
obscura_render::AttributeStyleMutation {
Some(obscura_render::AttributeStyleMutation {
node,
name: arg2.to_string(),
old_value: keeps_selector_value
@@ -525,8 +566,8 @@ fn retained_style_mutation(
.flatten(),
new_value: None,
}
.into()
}),
.into())
}
"append_child" => {
let child = NodeId::new(arg2.parse::<u32>().ok()?);
dom.get_node(node)?;
@@ -559,13 +600,19 @@ fn retained_style_mutation(
.into(),
)
}
"set_text_content" => Some(
obscura_render::TreeStyleMutation::Text {
node,
parent: dom.get_node(node)?.parent,
}
.into(),
),
"set_text_content" => match &dom.get_node(node)?.data {
NodeData::Text { .. } => Some(
obscura_render::TreeStyleMutation::Text {
node,
parent: dom.get_node(node)?.parent,
}
.into(),
),
// Element/fragment textContent replaces a child list. That can
// flip :empty and structural/relational selectors, so the local
// text fast path cannot describe the mutation safely.
_ => None,
},
_ => None,
}
}
@@ -725,11 +772,68 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
.element_scroll_offsets
.retain(|node, _| !reset_nodes.contains(node));
state.scroll_generation = state.scroll_generation.wrapping_add(1);
if invalidate {
state.animation_timeline.remove_subtree(reset_nodes.iter());
}
}
#[cfg(feature = "render")]
let had_prepared_render = state.prepared_render.is_some();
#[cfg(feature = "render")]
if invalidate {
let mutation_time_ms = (state.animation_timeline_origin.elapsed().as_secs_f64()
* 1_000.0)
.min(f64::from(f32::MAX)) as f32;
// Keep animation birth epochs local to the changed subtree. A
// single document-global timestamp made a later unrelated write
// restart every not-yet-sampled animation at the same instant.
let direct_root = match cmd.as_str() {
"append_child" => arg2.parse::<u32>().ok(),
"insert_before"
| "set_attribute"
| "remove_attribute"
| "set_attribute_ns"
| "remove_attribute_ns" => arg1.parse::<u32>().ok(),
_ => None,
}
.map(NodeId::new);
let direct_nodes = direct_root
.and_then(|root| {
state.dom.as_ref().map(|dom| {
std::iter::once(root)
.chain(dom.descendants(root))
.collect::<Vec<_>>()
})
})
.unwrap_or_default();
for node in direct_nodes {
state
.animation_timeline
.note_start_candidate(node, mutation_time_ms);
}
let scope_root = match cmd.as_str() {
"append_child" => arg1.parse::<u32>().ok().map(NodeId::new),
"insert_before" => arg2
.parse::<u32>()
.ok()
.map(NodeId::new)
.and_then(|reference| {
state.dom.as_ref()?.get_node(reference)?.parent
}),
"remove_child" => arg1
.parse::<u32>()
.ok()
.map(NodeId::new)
.and_then(|child| state.dom.as_ref()?.get_node(child)?.parent),
"set_inner_html" | "set_inner_html_context" | "set_text_content" => {
arg1.parse::<u32>().ok().map(NodeId::new)
}
_ => None,
};
if let Some(root) = scope_root {
state
.animation_timeline
.note_subtree_start_candidate(root, mutation_time_ms);
}
if let Some(mutation) = retained_style_mutation {
if state.prepared_render.is_some()
&& state.pending_style_mutations.len() < 256
@@ -1556,20 +1660,6 @@ async fn op_fetch_url(
url
);
if let Ok(parsed_url) = url::Url::parse(&url) {
if let Err(e) = validate_fetch_url(&parsed_url) {
return Ok(serde_json::json!({
"status": 0,
"body": "",
"url": url,
"headers": {},
"blocked": true,
"error": e,
})
.to_string());
}
}
let (cookie_jar, in_flight, page_in_flight, intercept_tx, proxy_url, callbacks, http_client) = {
let state_borrow = state.borrow();
let gs = state_borrow.borrow::<SharedState>().clone();
@@ -1622,6 +1712,26 @@ async fn op_fetch_url(
gs.http_client.clone(),
)
};
// The private-network opt-in is a BrowserContext policy, not only a
// process-wide environment setting. Navigation already honours the
// context's configured HTTP client; scripted fetch/XHR must use the same
// policy for its initial URL and every URL it can reach below.
let allow_private_network = http_client
.as_ref()
.is_some_and(|client| client.allow_private_network);
if let Ok(parsed_url) = url::Url::parse(&url) {
if let Err(e) = validate_fetch_url(&parsed_url, allow_private_network) {
return Ok(serde_json::json!({
"status": 0,
"body": "",
"url": url,
"headers": {},
"blocked": true,
"error": e,
})
.to_string());
}
}
struct PageInFlightGuard(Arc<std::sync::atomic::AtomicU32>);
impl Drop for PageInFlightGuard {
fn drop(&mut self) {
@@ -1707,7 +1817,7 @@ async fn op_fetch_url(
// bypass validate_fetch_url entirely.
let url = if let Some(new_url) = override_url {
if let Ok(parsed) = url::Url::parse(&new_url) {
if let Err(reason) = validate_fetch_url(&parsed) {
if let Err(reason) = validate_fetch_url(&parsed, allow_private_network) {
return Ok(serde_json::json!({
"status": 0,
"body": "",
@@ -1838,6 +1948,7 @@ async fn op_fetch_url(
mode.clone(),
credentials,
callbacks.clone(),
allow_private_network,
)
.await;
}
@@ -1947,7 +2058,7 @@ async fn op_fetch_url(
};
// Re-validate every redirect target against the SSRF policy.
if let Err(reason) = validate_fetch_url(&next_url) {
if let Err(reason) = validate_fetch_url(&next_url, allow_private_network) {
return Ok(serde_json::json!({
"status": 0,
"body": "",
@@ -2143,6 +2254,7 @@ async fn stealth_fetch_all(
mode: String,
credentials: FetchCredentials,
callbacks: Option<Arc<CallbackRegistry>>,
allow_private_network: bool,
) -> Result<String, deno_error::JsErrorBox> {
let mut current_url = url.clone();
let mut current_method = method;
@@ -2194,7 +2306,7 @@ async fn stealth_fetch_all(
};
// Re-validate every redirect target against the SSRF policy, matching
// op_fetch_url (GHSA-8v6v-g4rh-jmcm).
if let Err(reason) = validate_fetch_url(&next_url) {
if let Err(reason) = validate_fetch_url(&next_url, allow_private_network) {
return Ok(serde_json::json!({
"status": 0, "body": "", "url": next_url.to_string(), "headers": {},
"blocked": true,
@@ -2305,7 +2417,7 @@ fn glob_match(pattern: &str, url: &str) -> bool {
#[cfg(test)]
mod tests {
use super::{cors_response_allows, glob_match, FetchCredentials};
use super::{cors_response_allows, glob_match, validate_fetch_url, FetchCredentials};
#[test]
fn glob_match_handles_cdp_blocked_url_patterns() {
@@ -2378,9 +2490,15 @@ mod tests {
"true",
));
}
#[test]
fn fetch_url_validation_honors_per_context_private_network_opt_in() {
let loopback = url::Url::parse("http://127.0.0.1:8080/resource").unwrap();
assert!(validate_fetch_url(&loopback, true).is_ok());
}
}
fn validate_fetch_url(url: &url::Url) -> Result<(), String> {
fn validate_fetch_url(url: &url::Url, allow_private_network: bool) -> Result<(), String> {
let scheme = url.scheme();
if scheme != "http" && scheme != "https" && scheme != "file" {
return Err(format!(
@@ -2389,7 +2507,10 @@ fn validate_fetch_url(url: &url::Url) -> Result<(), String> {
));
}
if scheme == "file" || obscura_net::env_allows_private_network() {
if scheme == "file"
|| allow_private_network
|| obscura_net::env_allows_private_network()
{
return Ok(());
}
@@ -3119,8 +3240,10 @@ pub fn build_extension() -> Extension {
// probes with typeof before calling, so the op's absence is a clean fallback.
#[cfg(feature = "render")]
{
ops.push(op_begin_render_task());
ops.push(op_set_dynamic_fonts());
ops.push(op_image_metadata());
ops.push(op_load_image_metadata());
ops.push(op_layout_geometry());
ops.push(op_computed_style());
ops.push(op_css_supports());
@@ -3161,16 +3284,24 @@ pub(crate) fn ensure_prepared_render(
) -> Option<&obscura_render::PreparedRender> {
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| {
prepared.viewport() != viewport || prepared.base_url() != base_url.as_deref()
prepared.viewport() != viewport
|| prepared.base_url() != base_url.as_deref()
|| prepared.animation_sample() != animation_sample
});
if stale || !state.pending_style_mutations.is_empty() {
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 mutations = std::mem::take(&mut state.pending_style_mutations);
let prepared = {
let dom = state.dom.as_ref()?;
match previous {
Some(previous) => obscura_render::prepare_dom_with_retained_styles(
Some(previous) => obscura_render::prepare_dom_with_retained_styles_with_animation_state(
dom,
viewport,
base_url.as_deref(),
@@ -3179,33 +3310,92 @@ pub(crate) fn ensure_prepared_render(
&mut state.stylesheet_cache,
previous,
&mutations,
animation_sample,
&mut state.animation_timeline,
)
.or_else(|| {
obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache(
obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
dom,
viewport,
base_url.as_deref(),
&mut state.render_resources,
&state.dynamic_fonts,
&mut state.stylesheet_cache,
animation_sample,
&mut state.animation_timeline,
)
})?,
None => obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache(
None => obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
dom,
viewport,
base_url.as_deref(),
&mut state.render_resources,
&state.dynamic_fonts,
&mut state.stylesheet_cache,
animation_sample,
&mut state.animation_timeline,
)?,
}
};
if animation_sample.mode == obscura_render::AnimationSampleMode::DocumentTime {
state.animation_timeline.clear_start_candidates();
}
let connected = state.dom.as_ref().map(|dom| {
std::iter::once(dom.document())
.chain(dom.descendants(dom.document()))
.collect::<HashSet<_>>()
});
if let Some(connected) = connected {
state
.animation_timeline
.retain_nodes(|node| connected.contains(&node));
}
state.prepared_render = Some(prepared);
state.resolved_scroll = None;
}
state.prepared_render.as_ref()
}
#[cfg(feature = "render")]
pub(crate) fn sample_live_document_animations(state: &mut ObscuraState) {
if state.animation_sampled_task_generation == state.animation_task_generation {
return;
}
state.animation_sampled_task_generation = state.animation_task_generation;
let sample = obscura_render::AnimationSample::document(
(state.animation_timeline_origin.elapsed().as_secs_f64() * 1_000.0)
.min(f64::from(f32::MAX)) as f32,
);
if state.animation_sample == sample {
return;
}
if sample.time.milliseconds > state.animation_sample.time.milliseconds
&& state.animation_sample.mode == obscura_render::AnimationSampleMode::DocumentTime
&& state.prepared_render.as_mut().is_some_and(|prepared| {
prepared.advance_inactive_animation_sample_time(sample.time)
})
{
state.animation_sample = sample;
return;
}
state.animation_sample = sample;
state.prepared_render = None;
state.pending_style_mutations.clear();
state.resolved_scroll = None;
}
#[cfg(feature = "render")]
pub(crate) fn begin_animation_task(state: &mut ObscuraState) {
state.animation_task_generation = state.animation_task_generation.wrapping_add(1);
}
#[cfg(feature = "render")]
#[op2(fast)]
fn op_begin_render_task(state: &OpState) {
let shared = state.borrow::<SharedState>().clone();
begin_animation_task(&mut shared.borrow_mut());
}
#[cfg(feature = "render")]
pub(crate) fn ensure_resolved_scroll(state: &mut ObscuraState) -> Option<()> {
ensure_prepared_render(state)?;
@@ -3248,81 +3438,13 @@ pub(crate) fn ensure_resolved_scroll(state: &mut ObscuraState) -> Option<()> {
Some(())
}
/// Probe one ordinary `<img src>` through the renderer's page-scoped resource
/// cache. This is deliberately a synchronous render-only op: the current
/// renderer loader is synchronous too, and sharing it is what guarantees that
/// HTMLImageElement lifecycle state and the subsequent paint see the same
/// success/failure and retained bytes.
#[cfg(feature = "render")]
#[op2]
#[string]
fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String {
let shared = state.borrow::<SharedState>().clone();
let mut gs = shared.borrow_mut();
let node_id = NodeId::new(nid);
let is_image = gs.dom.as_ref().is_some_and(|dom| {
dom.get_node(node_id).is_some_and(|node| {
node.as_element()
.is_some_and(|element| element.local.as_ref() == "img")
})
});
if !is_image {
return serde_json::json!({ "ok": false, "currentSrc": "" }).to_string();
}
let base_url = document_base_url(&gs);
let viewport = gs.viewport;
let previous_dimensions = (!cached_only)
.then(|| {
gs.dom.as_ref().and_then(|dom| {
gs.render_resources
.cached_image_element_metadata(
dom,
node_id,
viewport,
base_url.as_deref(),
)
.and_then(|(_, _, known, dimensions)| known.then_some(dimensions).flatten())
})
})
.flatten();
let ObscuraState {
dom,
render_resources,
..
} = &mut *gs;
let Some(dom) = dom.as_ref() else {
return serde_json::json!({ "ok": false, "currentSrc": "" }).to_string();
};
let (current_src, density, known, dimensions) = if cached_only {
match render_resources.cached_image_element_metadata(
dom,
node_id,
viewport,
base_url.as_deref(),
) {
Some(metadata) => metadata,
None => {
return serde_json::json!({
"state": "error",
"ok": false,
"currentSrc": "",
})
.to_string();
}
}
} else {
match render_resources.image_element_metadata(dom, node_id, viewport, base_url.as_deref()) {
Some((current_src, density, dimensions)) => (current_src, density, true, dimensions),
None => {
return serde_json::json!({
"state": "error",
"ok": false,
"currentSrc": "",
})
.to_string();
}
}
};
fn image_metadata_json(
current_src: String,
density: f32,
known: bool,
dimensions: Option<(f32, f32)>,
) -> String {
if !known {
return serde_json::json!({
"state": "pending",
@@ -3331,15 +3453,6 @@ fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String {
})
.to_string();
}
// The page transport invalidates prepared layout when it seeds successful
// bytes. Cover the synchronous compatibility-loader path too, but only
// when this call actually discovers a new decodable intrinsic size. A
// cache hit or retained decode failure cannot change used geometry.
if !cached_only && dimensions.is_some() && dimensions != previous_dimensions {
gs.prepared_render = None;
gs.pending_style_mutations.clear();
gs.resolved_scroll = None;
}
match dimensions {
Some((width, height)) => serde_json::json!({
"state": "loaded",
@@ -3360,6 +3473,348 @@ fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String {
}
}
#[cfg(feature = "render")]
fn image_request_profile(dom: &DomTree, node_id: NodeId) -> ImageRequestProfile {
match dom
.get_node(node_id)
.and_then(|node| node.get_attribute("crossorigin").map(str::to_owned))
.map(|value| value.trim().to_ascii_lowercase())
.as_deref()
{
Some("use-credentials") => ImageRequestProfile::CorsInclude,
Some(_) => ImageRequestProfile::CorsSameOrigin,
None => ImageRequestProfile::NoCorsInclude,
}
}
#[cfg(feature = "render")]
fn profiled_cached_image_metadata(
gs: &ObscuraState,
node_id: NodeId,
) -> Option<(String, f32, bool, Option<(f32, f32)>)> {
let dom = gs.dom.as_ref()?;
let base_url = document_base_url(gs);
gs.render_resources.cached_image_element_metadata(
dom,
node_id,
gs.viewport,
base_url.as_deref(),
)
}
#[cfg(feature = "render")]
fn cached_image_metadata_for_node(gs: &ObscuraState, node_id: NodeId) -> String {
match profiled_cached_image_metadata(gs, node_id) {
Some((current_src, density, known, dimensions)) => {
image_metadata_json(current_src, density, known, dimensions)
}
None => serde_json::json!({ "ok": false, "currentSrc": "" }).to_string(),
}
}
/// Probe one ordinary `<img>` through the renderer's page-scoped resource
/// cache. This op is intentionally cache-only. Lifecycle getters call it
/// synchronously and must never open a socket or wait on network I/O.
#[cfg(feature = "render")]
#[op2]
#[string]
fn op_image_metadata(state: &OpState, nid: u32, _cached_only: bool) -> String {
let shared = state.borrow::<SharedState>().clone();
let gs = shared.borrow();
let node_id = NodeId::new(nid);
let is_image = gs.dom.as_ref().is_some_and(|dom| {
dom.get_node(node_id).is_some_and(|node| {
node.as_element()
.is_some_and(|element| element.local.as_ref() == "img")
})
});
if !is_image {
return serde_json::json!({ "ok": false, "currentSrc": "" }).to_string();
}
cached_image_metadata_for_node(&gs, node_id)
}
/// Compatibility path for standalone render runtimes which deliberately
/// install an in-memory `RenderResourceLoader` but have no owning page
/// transport. Browser pages always install `ObscuraHttpClient` before page
/// script runs and never enter this synchronous loader.
#[cfg(feature = "render")]
fn load_image_metadata_without_page_transport(gs: &mut ObscuraState, node_id: NodeId) -> String {
let base_url = document_base_url(&gs);
let viewport = gs.viewport;
let previous_dimensions = gs.dom.as_ref().and_then(|dom| {
gs.render_resources
.cached_image_element_metadata(dom, node_id, viewport, base_url.as_deref())
.and_then(|(_, _, known, dimensions)| known.then_some(dimensions).flatten())
});
let Some(dom) = gs.dom.as_ref() else {
return serde_json::json!({ "ok": false, "currentSrc": "" }).to_string();
};
let Some((current_src, density, dimensions)) = gs.render_resources.image_element_metadata(
dom,
node_id,
viewport,
base_url.as_deref(),
) else {
return serde_json::json!({
"state": "error",
"ok": false,
"currentSrc": "",
})
.to_string();
};
if dimensions.is_some() && dimensions != previous_dimensions {
gs.prepared_render = None;
gs.pending_style_mutations.clear();
gs.resolved_scroll = None;
}
image_metadata_json(current_src, density, true, dimensions)
}
#[cfg(feature = "render")]
fn finish_async_image_metadata(
shared: &SharedState,
node_id: NodeId,
document_generation: u64,
expected_url: &str,
request_profile: ImageRequestProfile,
) -> String {
let mut gs = shared.borrow_mut();
if gs.document_generation != document_generation {
return serde_json::json!({ "state": "stale", "currentSrc": expected_url })
.to_string();
}
let Some(dom) = gs.dom.as_ref() else {
return serde_json::json!({ "state": "stale", "currentSrc": expected_url })
.to_string();
};
if image_request_profile(dom, node_id) != request_profile {
return serde_json::json!({ "state": "stale", "currentSrc": expected_url })
.to_string();
}
let Some((current_src, density, known, dimensions)) =
profiled_cached_image_metadata(&gs, node_id)
else {
return serde_json::json!({ "state": "stale", "currentSrc": expected_url })
.to_string();
};
if current_src != expected_url {
return serde_json::json!({ "state": "stale", "currentSrc": current_src }).to_string();
}
if known && dimensions.is_some() {
// This request began only because the selected resource was unknown.
// Newly available intrinsic dimensions can dirty ancestor geometry,
// exactly like Gecko's SIZE_AVAILABLE reflow notification.
gs.prepared_render = None;
gs.pending_style_mutations.clear();
gs.resolved_scroll = None;
}
image_metadata_json(current_src, density, known, dimensions)
}
/// Load HTMLImageElement bytes through the owning page's async transport.
/// Network runs after every RefCell borrow is released, requests for the same
/// navigation/URL/profile share one fetch, and completion revalidates both the
/// document identity and responsive candidate before exposing lifecycle state.
#[cfg(feature = "render")]
#[op2(async)]
#[string]
async fn op_load_image_metadata(state: Rc<RefCell<OpState>>, nid: u32) -> String {
let shared = {
let state = state.borrow();
state.borrow::<SharedState>().clone()
};
let node_id = NodeId::new(nid);
let (
document_generation,
selected_url,
request_profile,
resource_request,
http_client,
callbacks,
page_in_flight,
blocked,
) = {
let gs = shared.borrow();
let Some(dom) = gs.dom.as_ref() else {
return serde_json::json!({ "state": "stale", "currentSrc": "" }).to_string();
};
let is_image = dom.get_node(node_id).is_some_and(|node| {
node.as_element()
.is_some_and(|element| element.local.as_ref() == "img")
});
if !is_image {
return serde_json::json!({ "state": "stale", "currentSrc": "" }).to_string();
}
let profile = image_request_profile(dom, node_id);
let Some((selected_url, _, known, _)) =
profiled_cached_image_metadata(&gs, node_id)
else {
return serde_json::json!({ "state": "error", "ok": false, "currentSrc": "" })
.to_string();
};
if known {
return cached_image_metadata_for_node(&gs, node_id);
}
let initiator = url::Url::parse(&gs.url)
.or_else(|_| url::Url::parse(&selected_url))
.unwrap_or_else(|_| url::Url::parse("about:blank").unwrap());
let mut request = ResourceRequest::subresource(ResourceType::Image, &initiator);
match profile {
ImageRequestProfile::CorsInclude => {
request.mode = RequestMode::Cors;
request.credentials = RequestCredentials::Include;
}
ImageRequestProfile::CorsSameOrigin => {
request.mode = RequestMode::Cors;
request.credentials = RequestCredentials::SameOrigin;
}
ImageRequestProfile::NoCorsInclude => {}
}
let blocked = gs.blocked_urls.iter().any(|pattern| {
pattern == "*" || selected_url.contains(pattern) || glob_match(pattern, &selected_url)
});
(
gs.document_generation,
selected_url,
profile,
request,
gs.http_client.clone(),
gs.callbacks.clone(),
Arc::clone(&gs.page_in_flight),
blocked,
)
};
#[cfg(feature = "stealth")]
let stealth_client = shared.borrow().stealth_client.clone();
#[cfg(feature = "stealth")]
let has_page_transport = http_client.is_some() || stealth_client.is_some();
#[cfg(not(feature = "stealth"))]
let has_page_transport = http_client.is_some();
if !has_page_transport {
return load_image_metadata_without_page_transport(&mut shared.borrow_mut(), node_id);
}
// Different CORS/credential profiles do not share an in-flight response.
let request_key = (document_generation, selected_url.clone(), request_profile);
let follower = {
let mut gs = shared.borrow_mut();
if let Some(waiters) = gs.render_image_in_flight.get_mut(&request_key) {
let (sender, receiver) = tokio::sync::oneshot::channel();
waiters.push(sender);
Some(receiver)
} else {
gs.render_image_in_flight.insert(request_key.clone(), Vec::new());
None
}
};
if let Some(receiver) = follower {
let _ = receiver.await;
return finish_async_image_metadata(
&shared,
node_id,
document_generation,
&selected_url,
request_profile,
);
}
struct PageImageInFlightGuard(Arc<std::sync::atomic::AtomicU32>);
impl Drop for PageImageInFlightGuard {
fn drop(&mut self) {
self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
}
}
page_in_flight.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let _page_in_flight = PageImageInFlightGuard(page_in_flight);
let parsed_url = url::Url::parse(&selected_url).ok();
let response = if blocked || parsed_url.is_none() {
None
} else {
let parsed_url = parsed_url.as_ref().unwrap();
#[cfg(feature = "stealth")]
{
if let Some(client) = stealth_client {
client
.fetch_resource_with_callbacks(
parsed_url,
resource_request.clone(),
callbacks.as_deref(),
)
.await
.ok()
} else {
http_client
.as_ref()
.unwrap()
.fetch_resource_with_callbacks(
parsed_url,
resource_request,
callbacks.as_deref(),
)
.await
.ok()
}
}
#[cfg(not(feature = "stealth"))]
{
http_client
.as_ref()
.unwrap()
.fetch_resource_with_callbacks(
parsed_url,
resource_request,
callbacks.as_deref(),
)
.await
.ok()
}
};
let bytes = response.and_then(|response| {
(200..300)
.contains(&response.status)
.then_some(response.body)
});
let waiters = {
let mut gs = shared.borrow_mut();
if gs.document_generation == document_generation {
match bytes {
Some(bytes) => {
if obscura_render::image_intrinsic_dimensions(&bytes).is_some() {
gs.render_resources.seed_image(
selected_url.clone(),
request_profile,
bytes,
);
} else {
gs.render_resources
.seed_image_missing(selected_url.clone(), request_profile);
}
}
None => {
gs.render_resources
.seed_image_missing(selected_url.clone(), request_profile);
}
}
}
gs.render_image_in_flight
.remove(&request_key)
.unwrap_or_default()
};
for waiter in waiters {
let _ = waiter.send(());
}
finish_async_image_metadata(
&shared,
node_id,
document_generation,
&selected_url,
request_profile,
)
}
#[cfg(feature = "render")]
pub(crate) fn clamp_scroll_offset(state: &mut ObscuraState, requested: (f32, f32)) -> (f32, f32) {
let clamped = ensure_prepared_render(state)
@@ -3391,6 +3846,7 @@ fn op_layout_geometry(state: &OpState, #[string] nid_str: String) -> String {
let nid: u32 = nid_str.parse().unwrap_or(0);
let nid = obscura_dom::tree::NodeId::new(nid);
let mut gs = shared.borrow_mut();
sample_live_document_animations(&mut gs);
if ensure_resolved_scroll(&mut gs).is_some() {
let Some((_, scroll)) = gs.resolved_scroll.as_ref() else {
return String::new();
@@ -3445,6 +3901,7 @@ fn op_computed_style(state: &OpState, #[string] nid_str: String) -> String {
let nid: u32 = nid_str.parse().unwrap_or(0);
let nid = obscura_dom::tree::NodeId::new(nid);
let mut gs = shared.borrow_mut();
sample_live_document_animations(&mut gs);
let Some(prepared) = ensure_prepared_render(&mut gs) else {
return String::new();
};
@@ -3480,6 +3937,7 @@ fn op_css_supports(#[string] name: &str, #[string] value: &str) -> bool {
fn op_layout_metrics(state: &OpState) -> String {
let shared = state.borrow::<SharedState>().clone();
let mut gs = shared.borrow_mut();
sample_live_document_animations(&mut gs);
let viewport = gs.viewport;
let content = ensure_prepared_render(&mut gs)
.map(|prepared| prepared.content_size())
@@ -3497,6 +3955,7 @@ fn op_element_scroll_metrics(state: &OpState, #[string] nid_str: String) -> Stri
let shared = state.borrow::<SharedState>().clone();
let nid = NodeId::new(nid_str.parse().unwrap_or(0));
let mut gs = shared.borrow_mut();
sample_live_document_animations(&mut gs);
if ensure_resolved_scroll(&mut gs).is_none() {
return String::new();
}
@@ -3533,6 +3992,7 @@ fn op_element_scroll_to(state: &OpState, #[string] nid_str: String, x: f64, y: f
let shared = state.borrow::<SharedState>().clone();
let nid = NodeId::new(nid_str.parse().unwrap_or(0));
let mut gs = shared.borrow_mut();
sample_live_document_animations(&mut gs);
if ensure_resolved_scroll(&mut gs).is_none() {
return String::new();
}
@@ -3575,6 +4035,7 @@ fn op_element_scroll_to(state: &OpState, #[string] nid_str: String, x: f64, y: f
fn op_scroll_offset(state: &OpState) -> String {
let shared = state.borrow::<SharedState>().clone();
let mut gs = shared.borrow_mut();
sample_live_document_animations(&mut gs);
let requested = gs.scroll_offset;
let (x, y) = clamp_scroll_offset(&mut gs, requested);
format!("{{\"x\":{},\"y\":{}}}", x, y)
@@ -3586,6 +4047,7 @@ fn op_scroll_offset(state: &OpState) -> String {
fn op_scroll_to(state: &OpState, x: f64, y: f64) -> String {
let shared = state.borrow::<SharedState>().clone();
let mut gs = shared.borrow_mut();
sample_live_document_animations(&mut gs);
let (x, y) = clamp_scroll_offset(&mut gs, (x as f32, y as f32));
format!("{{\"x\":{},\"y\":{}}}", x, y)
}
File diff suppressed because it is too large. Load diff
+284 -6
View File
@@ -1290,6 +1290,19 @@ fn note_compound_dependencies(
}
}
}
"dir" | "lang" => {
// The corresponding HTML attributes inherit through
// descendants, while the functional selector may
// observe language/direction resolved above the
// mutated node. Keep the dependency keyed, but require
// the retained planner's sound full fallback.
map.push_state(
name,
rule_order,
InvalidationReaches::CONSERVATIVE,
);
map.mark_conservative(rule_order);
}
_ => {
// A compiled functional pseudo outside the explicitly
// modeled set may hide selector or document state.
@@ -1449,6 +1462,8 @@ fn note_declaration_attribute_dependencies(
fn selector_requires_conservative_tracking(selector: &str) -> bool {
let selector = selector.to_ascii_lowercase();
selector.contains(":has(")
|| selector.contains(":dir(")
|| selector.contains(":lang(")
|| selector.contains(":nth-child(")
|| selector.contains(":nth-last-child(")
|| selector.contains(":nth-of-type(")
@@ -1638,6 +1653,86 @@ impl PseudoRuleMap {
}
self.rules.push(rule);
}
fn bucket_matches_container_query_rule(
&self,
bucket: Option<&Vec<usize>>,
tree: &DomTree,
matcher: &mut Matcher,
nid: NodeId,
) -> bool {
bucket.is_some_and(|indices| {
indices.iter().copied().any(|index| {
let rule = &self.rules[index];
rule.container_condition_id != ContainerConditionId::NONE
&& (rule.candidate_slot == NO_CANDIDATE_SLOT
|| matcher.mark_candidate(rule.candidate_slot as usize))
&& matcher.matches(tree, nid, &rule.sel)
})
})
}
fn node_matches_container_query_rule(
&self,
tree: &DomTree,
matcher: &mut Matcher,
nid: NodeId,
) -> bool {
if self.candidate_slot_count != 0 {
matcher.begin_candidate_collection(self.candidate_slot_count);
}
let Some(node) = tree.get_node(nid) else {
return false;
};
node.as_element().is_some_and(|element| {
self.bucket_matches_container_query_rule(
self.by_local.get(element.local.as_ref()),
tree,
matcher,
nid,
) || (is_root_element(tree, nid)
&& self.bucket_matches_container_query_rule(
(!self.by_root.is_empty()).then_some(&self.by_root),
tree,
matcher,
nid,
))
|| node.get_attribute("id").is_some_and(|id| {
self.bucket_matches_container_query_rule(
self.by_id.get(id),
tree,
matcher,
nid,
)
})
|| node.get_attribute("class").is_some_and(|classes| {
classes.split_whitespace().any(|class| {
self.bucket_matches_container_query_rule(
self.by_class.get(class),
tree,
matcher,
nid,
)
})
})
|| node.attrs().is_some_and(|attributes| {
attributes.iter().any(|attribute| {
self.bucket_matches_container_query_rule(
self.by_attribute.get(attribute.name.local.as_ref()),
tree,
matcher,
nid,
)
})
})
|| self.bucket_matches_container_query_rule(
(!self.universal.is_empty()).then_some(&self.universal),
tree,
matcher,
nid,
)
})
}
}
/// A cascade layer's first-declaration position at each nesting level.
@@ -2330,6 +2425,98 @@ impl Stylesheet {
.any(|rule| rule.container_condition_id != ContainerConditionId::NONE)
}
/// Whether `nid` can receive a declaration from any container-conditional
/// rule in the current DOM state. Retained style uses this to reset only
/// conditional subjects (and their inherited subtrees) for its query-free
/// seed pass instead of discarding every clean computed style merely
/// because the document owns a query container.
fn bucket_matches_container_query_rule(
&self,
bucket: Option<&Vec<usize>>,
tree: &DomTree,
matcher: &mut Matcher,
nid: NodeId,
) -> bool {
bucket.is_some_and(|indices| {
indices.iter().copied().any(|index| {
let rule = &self.rules[index];
rule.container_condition_id != ContainerConditionId::NONE
&& (rule.candidate_slot == NO_CANDIDATE_SLOT
|| matcher.mark_candidate(rule.candidate_slot as usize))
&& matcher.matches(tree, nid, &rule.sel)
})
})
}
pub(crate) fn node_matches_container_query_rule(
&self,
tree: &DomTree,
matcher: &mut Matcher,
nid: NodeId,
) -> bool {
if self.candidate_slot_count != 0 {
matcher.begin_candidate_collection(self.candidate_slot_count);
}
let Some(node) = tree.get_node(nid) else {
return false;
};
let normal_match = node.as_element().is_some_and(|element| {
self.bucket_matches_container_query_rule(
self.by_local.get(element.local.as_ref()),
tree,
matcher,
nid,
) || (is_root_element(tree, nid)
&& self.bucket_matches_container_query_rule(
(!self.by_root.is_empty()).then_some(&self.by_root),
tree,
matcher,
nid,
))
|| node.get_attribute("id").is_some_and(|id| {
self.bucket_matches_container_query_rule(
self.by_id.get(id),
tree,
matcher,
nid,
)
})
|| node.get_attribute("class").is_some_and(|classes| {
classes.split_whitespace().any(|class| {
self.bucket_matches_container_query_rule(
self.by_class.get(class),
tree,
matcher,
nid,
)
})
})
|| node.attrs().is_some_and(|attributes| {
attributes.iter().any(|attribute| {
self.bucket_matches_container_query_rule(
self.by_attribute.get(attribute.name.local.as_ref()),
tree,
matcher,
nid,
)
})
})
|| self.bucket_matches_container_query_rule(
(!self.universal.is_empty()).then_some(&self.universal),
tree,
matcher,
nid,
)
});
normal_match
|| self
.before_rules
.node_matches_container_query_rule(tree, matcher, nid)
|| self
.after_rules
.node_matches_container_query_rule(tree, matcher, nid)
}
pub(crate) fn container_condition_depth(&self) -> usize {
self.container_conditions
.iter()
@@ -2860,6 +3047,39 @@ impl Stylesheet {
style: &mut LayoutStyle,
parent_props: &HashMap<String, String>,
inline_css: Option<&str>,
) -> Option<HashMap<String, String>> {
let mut animation_timeline = crate::AnimationTimelineState::default();
self.apply_at_animation_time(
tree,
matcher,
nid,
id,
classes,
local,
style,
parent_props,
inline_css,
crate::AnimationSample {
time: self.animation_sample_time,
mode: crate::AnimationSampleMode::DocumentTime,
},
&mut animation_timeline,
)
}
pub(crate) fn apply_at_animation_time(
&self,
tree: &DomTree,
matcher: &mut Matcher,
nid: NodeId,
id: Option<&str>,
classes: &[String],
local: &str,
style: &mut LayoutStyle,
parent_props: &HashMap<String, String>,
inline_css: Option<&str>,
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> Option<HashMap<String, String>> {
self.apply_internal(
tree,
@@ -2872,9 +3092,12 @@ impl Stylesheet {
parent_props,
inline_css,
None,
animation_sample,
animation_timeline,
)
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn apply_with_container_queries(
&self,
tree: &DomTree,
@@ -2887,6 +3110,41 @@ impl Stylesheet {
parent_props: &HashMap<String, String>,
inline_css: Option<&str>,
evaluator: &mut ContainerQueryEvaluator<'_>,
) -> Option<HashMap<String, String>> {
let mut animation_timeline = crate::AnimationTimelineState::default();
self.apply_with_container_queries_at_animation_time(
tree,
matcher,
nid,
id,
classes,
local,
style,
parent_props,
inline_css,
evaluator,
crate::AnimationSample {
time: self.animation_sample_time,
mode: crate::AnimationSampleMode::DocumentTime,
},
&mut animation_timeline,
)
}
pub(crate) fn apply_with_container_queries_at_animation_time(
&self,
tree: &DomTree,
matcher: &mut Matcher,
nid: NodeId,
id: Option<&str>,
classes: &[String],
local: &str,
style: &mut LayoutStyle,
parent_props: &HashMap<String, String>,
inline_css: Option<&str>,
evaluator: &mut ContainerQueryEvaluator<'_>,
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> Option<HashMap<String, String>> {
self.apply_internal(
tree,
@@ -2899,6 +3157,8 @@ impl Stylesheet {
parent_props,
inline_css,
Some(evaluator),
animation_sample,
animation_timeline,
)
}
@@ -2914,6 +3174,8 @@ impl Stylesheet {
parent_props: &HashMap<String, String>,
inline_css: Option<&str>,
mut evaluator: Option<&mut ContainerQueryEvaluator<'_>>,
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> Option<HashMap<String, String>> {
// Keep the two cascade priorities separate from the outset. A typical
// stylesheet has very few important declarations, so cloning and
@@ -3265,6 +3527,7 @@ impl Stylesheet {
|| important_matched
.iter()
.any(|&(_, _, i)| self.rules[i].important_flags.has_animation);
style.animation_has_render_effect = false;
if !self.keyframes.is_empty() && (style.animation_name.is_some() || important_has_animation)
{
let mut animation_style = style.clone();
@@ -3290,15 +3553,31 @@ impl Stylesheet {
}
if let Some(name) = animation_style.animation_name.as_deref() {
if let Some(keyframes) = self.keyframes.get(name) {
style.animation_has_render_effect = !keyframes.tracks.is_empty();
let local_sample = animation_timeline.sample_for(
nid,
crate::AnimationInstanceKey {
name: name.to_string(),
},
animation_style.animation_timing.play_state,
animation_sample,
);
style.animation_local_time_ms = local_sample.milliseconds;
sample_animation_properties(
keyframes,
style,
&animation_style.animation_timing,
self.animation_sample_time,
local_sample,
props,
);
} else {
animation_timeline.clear_animation(nid, animation_sample);
}
} else {
animation_timeline.clear_animation(nid, animation_sample);
}
} else {
animation_timeline.clear_animation(nid, animation_sample);
}
for &(_, _, i) in &important_matched {
@@ -4251,11 +4530,10 @@ fn animation_directed_progress(
} else {
duration * iterations
};
let local_time = if timing.play_state == crate::AnimationPlayState::Paused {
0.0
} else {
sample_time.milliseconds
};
// The timeline owns pause/resume hold time. Keeping that state outside the
// sampler lets a running animation freeze at its current progress and
// later resume without manufacturing a new instance.
let local_time = sample_time.milliseconds;
if !local_time.is_finite() {
return None;
}
File diff suppressed because it is too large. Load diff
+199 -8
View File
@@ -97,15 +97,24 @@ mod image_capability_tests {
mod paint;
#[cfg(feature = "paint")]
pub use paint::{
paint_dom, paint_dom_scrolled, paint_prepared, paint_prepared_region_with_scroll,
paint_prepared_with_scroll, prepare_dom, prepare_dom_with_dynamic_fonts,
prepare_dom_with_dynamic_fonts_and_stylesheet_cache, screenshot_png, screenshot_png_scrolled,
image_intrinsic_dimensions, paint_dom, paint_dom_scrolled,
paint_dom_scrolled_at_animation_time, paint_prepared,
paint_prepared_region_with_scroll,
paint_prepared_with_scroll, prepare_dom, prepare_dom_at_animation_time,
prepare_dom_with_dynamic_fonts, prepare_dom_with_dynamic_fonts_at_animation_time,
prepare_dom_with_dynamic_fonts_and_stylesheet_cache,
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time,
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state,
screenshot_png, screenshot_png_scrolled, screenshot_png_scrolled_at_animation_time,
prepare_dom_with_retained_attribute_styles, prepare_dom_with_retained_styles,
prepare_dom_with_retained_styles_at_animation_time,
prepare_dom_with_retained_styles_with_animation_state,
screenshot_prepared,
screenshot_prepared_region_with_scroll,
screenshot_prepared_region_with_scroll_and_backgrounds, screenshot_prepared_with_scroll,
validate_capture_region, CaptureError, CaptureRegion, DynamicFontFace, ElementScrollMetrics,
PreparedRender, RenderResourceCache, RenderResourceLoader, ResolvedScrollState, SelectedImage,
ImageRequestProfile, PreparedRender, RenderResourceCache, RenderResourceLoader,
ResolvedScrollState, SelectedImage,
MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
};
@@ -1140,6 +1149,12 @@ pub struct LayoutStyle {
/// before author `!important`, matching the animation cascade origin.
pub animation_name: Option<String>,
pub animation_timing: AnimationTiming,
/// True when the selected keyframes contain at least one property this
/// renderer can sample. Unsupported custom-property-only animations must
/// not keep layout or screencast damage active forever.
pub animation_has_render_effect: bool,
/// Local time used for this element's sampled animation instance.
pub animation_local_time_ms: f32,
/// `vertical-align` for a table cell's content. Cells effectively default
/// to `middle` in browsers (the HTML UA sheet sets it on row groups and
/// cells inherit it); obscura applies it as main-axis alignment of the
@@ -1488,15 +1503,49 @@ pub enum WordBreak {
BreakWord,
}
/// Deterministic CSS animation sample time relative to the animation's
/// timeline origin. Screenshot rendering currently uses the initial value
/// (0ms); keeping it explicit lets capture plumbing freeze other engines at
/// the same instant instead of depending on wall-clock settling.
/// CSS animation sample time relative to the document timeline origin.
/// Live capture uses elapsed document time, while deterministic comparison
/// harnesses can request an exact instant such as T=0.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct AnimationSampleTime {
pub milliseconds: f32,
}
/// Which clock an animation sample represents.
///
/// Live rendering subtracts each element's animation-instance start epoch
/// from document time. Deterministic comparison instead assigns one local
/// time to every animation, matching Web Animations `currentTime` rather than
/// pretending all dynamically-created animations began at navigation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AnimationSampleMode {
#[default]
DocumentTime,
LocalOverride,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct AnimationSample {
pub time: AnimationSampleTime,
pub mode: AnimationSampleMode,
}
impl AnimationSample {
pub fn document(milliseconds: f32) -> Self {
Self {
time: AnimationSampleTime { milliseconds },
mode: AnimationSampleMode::DocumentTime,
}
}
pub fn local_override(milliseconds: f32) -> Self {
Self {
time: AnimationSampleTime { milliseconds },
mode: AnimationSampleMode::LocalOverride,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AnimationDirection {
#[default]
@@ -1549,6 +1598,148 @@ impl Default for AnimationTiming {
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct AnimationInstanceKey {
pub name: String,
}
#[derive(Debug, Clone)]
struct AnimationInstance {
key: AnimationInstanceKey,
start_ms: f32,
hold_time_ms: Option<f32>,
was_paused: bool,
}
/// Page-owned CSS animation instance history retained across layout rebuilds.
/// Node ids are document-scoped, so navigation must replace this value.
#[derive(Debug, Default)]
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>,
}
impl AnimationTimelineState {
pub fn note_start_candidate(
&mut self,
node: obscura_dom::tree::NodeId,
document_time_ms: f32,
) {
if document_time_ms.is_finite() && document_time_ms >= 0.0 {
self.start_candidates.insert(node, document_time_ms);
}
}
/// Defer expansion until the next style flush. This is needed for DOM
/// string APIs whose imported descendants do not exist until after the
/// mutation op has completed.
pub fn note_subtree_start_candidate(
&mut self,
root: obscura_dom::tree::NodeId,
document_time_ms: f32,
) {
if document_time_ms.is_finite() && document_time_ms >= 0.0 {
self.subtree_start_candidates
.insert(root, document_time_ms);
}
}
pub fn materialize_start_candidates(&mut self, tree: &obscura_dom::DomTree) {
for (root, start_ms) in std::mem::take(&mut self.subtree_start_candidates) {
if tree.get_node(root).is_none() {
continue;
}
self.start_candidates.insert(root, start_ms);
for node in tree.descendants(root) {
self.start_candidates.entry(node).or_insert(start_ms);
}
}
}
pub fn remove_subtree<'a>(
&mut self,
nodes: impl IntoIterator<Item = &'a obscura_dom::tree::NodeId>,
) {
for node in nodes {
self.instances.remove(node);
self.start_candidates.remove(node);
self.subtree_start_candidates.remove(node);
}
}
pub(crate) fn sample_for(
&mut self,
node: obscura_dom::tree::NodeId,
key: AnimationInstanceKey,
play_state: AnimationPlayState,
sample: AnimationSample,
) -> AnimationSampleTime {
if sample.mode == AnimationSampleMode::LocalOverride {
return sample.time;
}
let document_time_ms = sample.time.milliseconds;
let transition_time_ms = self.start_candidates.remove(&node);
let retained = self
.instances
.get_mut(&node)
.filter(|instance| instance.key == key);
let instance = match retained {
Some(instance) => instance,
None => {
let candidate = transition_time_ms.unwrap_or(0.0);
let paused = play_state == AnimationPlayState::Paused;
self.instances.insert(
node,
AnimationInstance {
key,
start_ms: if paused { document_time_ms } else { candidate },
hold_time_ms: paused.then_some(0.0),
was_paused: paused,
},
);
self.instances.get_mut(&node).expect("inserted animation instance")
}
};
let paused = play_state == AnimationPlayState::Paused;
if paused && !instance.was_paused {
let paused_at = transition_time_ms.unwrap_or(document_time_ms);
instance.hold_time_ms = Some((paused_at - instance.start_ms).max(0.0));
} else if !paused && instance.was_paused {
let held = instance.hold_time_ms.take().unwrap_or(0.0);
let resumed_at = transition_time_ms.unwrap_or(document_time_ms);
instance.start_ms = resumed_at - held;
}
instance.was_paused = paused;
AnimationSampleTime {
milliseconds: instance
.hold_time_ms
.unwrap_or_else(|| (document_time_ms - instance.start_ms).max(0.0)),
}
}
pub(crate) fn clear_animation(&mut self, node: obscura_dom::tree::NodeId, sample: AnimationSample) {
if sample.mode == AnimationSampleMode::DocumentTime {
self.instances.remove(&node);
}
}
pub fn retain_nodes(
&mut self,
mut keep: impl FnMut(obscura_dom::tree::NodeId) -> bool,
) {
self.instances.retain(|node, _| keep(*node));
self.start_candidates.retain(|node, _| keep(*node));
self.subtree_start_candidates.retain(|node, _| keep(*node));
}
pub fn clear_start_candidates(&mut self) {
self.start_candidates.clear();
self.subtree_start_candidates.clear();
}
}
/// The implemented `text-wrap-style` values. Other line-breaking strategies
/// such as `pretty` remain unsupported until their distinct scoring model is
/// available; treating them as `auto` would make `@supports` lie.
+535 -20
View File
@@ -23,8 +23,8 @@ static FONT_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-obl
static FONT_BOLD_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-boldoblique.ttf");
use crate::dom::{
layout_dom_with_web_fonts_and_retained_styles,
layout_dom_with_web_fonts_and_stylesheet_cache, RetainedStyleMaps,
layout_dom_with_web_fonts_and_retained_styles_with_animation_state,
layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_state, RetainedStyleMaps,
};
const DEFAULT_RESOURCE_CACHE_ENTRIES: usize = 512;
@@ -81,6 +81,49 @@ enum CachedResource {
Missing(std::time::Instant),
}
/// Fetch credentials/CORS identity for an HTML image request. No-CORS uses
/// the ordinary URL cache key so CSS images can share the same response;
/// CORS variants use private keys and can never contaminate one another.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum ImageRequestProfile {
NoCorsInclude,
CorsSameOrigin,
CorsInclude,
}
fn image_request_profile(tree: &DomTree, id: obscura_dom::tree::NodeId) -> ImageRequestProfile {
match tree
.get_node(id)
.and_then(|node| node.get_attribute("crossorigin").map(str::to_owned))
.map(|value| value.trim().to_ascii_lowercase())
.as_deref()
{
Some("use-credentials") => ImageRequestProfile::CorsInclude,
Some(_) => ImageRequestProfile::CorsSameOrigin,
None => ImageRequestProfile::NoCorsInclude,
}
}
fn image_resource_key(url: &str, profile: ImageRequestProfile) -> String {
let url = network_resource_url(url);
match profile {
ImageRequestProfile::NoCorsInclude => url,
ImageRequestProfile::CorsSameOrigin => format!("\0obscura-img-cors\0{url}"),
ImageRequestProfile::CorsInclude => format!("\0obscura-img-credentials\0{url}"),
}
}
fn network_resource_url(url: &str) -> String {
if url.starts_with('\0') || url.starts_with("data:") {
return url.to_string();
}
let Ok(mut parsed) = url::Url::parse(url) else {
return url.to_string();
};
parsed.set_fragment(None);
parsed.to_string()
}
#[derive(Clone, Debug, PartialEq)]
struct RememberedContentImageIntrinsic {
resolved_url: String,
@@ -162,13 +205,35 @@ impl RenderResourceCache {
}
pub fn has_live_outcome(&self, url: &str) -> bool {
match self.entries.get(url) {
match self.entries.get(&network_resource_url(url)) {
Some(CachedResource::Bytes(_)) => true,
Some(CachedResource::Missing(at)) => at.elapsed() < MISSING_RESOURCE_RETRY_AFTER,
None => false,
}
}
/// Whether this URL currently retains usable bytes (as opposed to a
/// short-lived negative-cache entry). Profile-specific HTML image loads
/// use this to avoid replacing paint bytes from another credential mode.
pub fn has_cached_bytes(&self, url: &str) -> bool {
matches!(
self.entries.get(&network_resource_url(url)),
Some(CachedResource::Bytes(_))
)
}
pub fn has_live_image_outcome(&self, url: &str, profile: ImageRequestProfile) -> bool {
self.has_live_outcome(&image_resource_key(url, profile))
}
pub fn seed_image(&mut self, url: String, profile: ImageRequestProfile, bytes: Vec<u8>) {
self.seed(image_resource_key(&url, profile), bytes);
}
pub fn seed_image_missing(&mut self, url: String, profile: ImageRequestProfile) {
self.seed_missing(image_resource_key(&url, profile));
}
/// Seed bytes fetched by the owning page's asynchronous browser transport.
///
/// Layout and paint are synchronous, so letting them open their own HTTP
@@ -176,6 +241,7 @@ impl RenderResourceCache {
/// layer uses this entry point to fetch a bounded resource batch through
/// its cookie/proxy/CORS-aware connection pool before entering layout.
pub fn seed(&mut self, url: String, bytes: Vec<u8>) {
let url = network_resource_url(&url);
self.remove(&url);
self.insert_bytes(url, Arc::from(bytes));
}
@@ -183,6 +249,7 @@ impl RenderResourceCache {
/// Retain a page-transport failure so capture does not immediately repeat
/// the same slow request through the renderer's compatibility loader.
pub fn seed_missing(&mut self, url: String) {
let url = network_resource_url(&url);
self.remove(&url);
self.insert_missing(url);
}
@@ -224,7 +291,30 @@ impl RenderResourceCache {
.map(|(width, height)| (resolved_url, width, height)),
);
}
match self.entries.get(&resolved_url) {
match self.entries.get(&network_resource_url(&resolved_url)) {
Some(CachedResource::Bytes(bytes)) => Some(
image_metadata_from_bytes(bytes)
.map(|(width, height)| (resolved_url, width, height)),
),
Some(CachedResource::Missing(at)) if at.elapsed() < MISSING_RESOURCE_RETRY_AFTER => {
Some(None)
}
_ => None,
}
}
fn cached_profiled_image_metadata(
&self,
src: &str,
base_url: Option<&str>,
profile: ImageRequestProfile,
) -> Option<Option<(String, f32, f32)>> {
let resolved_url = resolve_resource_url(src, base_url)?;
if resolved_url.starts_with("data:") {
return self.cached_image_metadata(&resolved_url, None);
}
let key = image_resource_key(&resolved_url, profile);
match self.entries.get(&key) {
Some(CachedResource::Bytes(bytes)) => Some(
image_metadata_from_bytes(bytes)
.map(|(width, height)| (resolved_url, width, height)),
@@ -250,7 +340,8 @@ impl RenderResourceCache {
) -> Option<(String, f32, Option<(f32, f32)>)> {
let (src, density) = resolve_img_url(tree, id, viewport)?;
let resolved_url = resolve_resource_url(&src, base_url).unwrap_or(src);
let dimensions = fetch_bytes(&resolved_url, None, self)
let profile = image_request_profile(tree, id);
let dimensions = fetch_profiled_image_bytes(&resolved_url, None, self, profile)
.and_then(|bytes| image_metadata_from_bytes(&bytes))
.map(|(width, height)| (width / density, height / density));
Some((resolved_url, density, dimensions))
@@ -268,7 +359,8 @@ impl RenderResourceCache {
) -> Option<(String, f32, bool, Option<(f32, f32)>)> {
let (src, density) = resolve_img_url(tree, id, viewport)?;
let resolved_url = resolve_resource_url(&src, base_url).unwrap_or(src);
match self.cached_image_metadata(&resolved_url, None) {
let profile = image_request_profile(tree, id);
match self.cached_profiled_image_metadata(&resolved_url, None, profile) {
None => Some((resolved_url, density, false, None)),
Some(dimensions) => Some((
resolved_url,
@@ -280,7 +372,8 @@ impl RenderResourceCache {
}
fn get_or_load(&mut self, url: &str) -> Option<Arc<[u8]>> {
if let Some(entry) = self.entries.get(url) {
let url = network_resource_url(url);
if let Some(entry) = self.entries.get(&url) {
match entry {
CachedResource::Bytes(bytes) => return Some(Arc::clone(bytes)),
CachedResource::Missing(at) if at.elapsed() < MISSING_RESOURCE_RETRY_AFTER => {
@@ -292,16 +385,53 @@ impl RenderResourceCache {
if !self.sync_loading_enabled {
return None;
}
self.remove(url);
self.remove(&url);
let loaded = self.loader.load(url).map(Arc::<[u8]>::from);
let loaded = self.loader.load(&url).map(Arc::<[u8]>::from);
match loaded {
Some(bytes) => {
self.insert_bytes(url.to_string(), Arc::clone(&bytes));
self.insert_bytes(url, Arc::clone(&bytes));
Some(bytes)
}
None => {
self.insert_missing(url.to_string());
self.insert_missing(url);
None
}
}
}
fn get_or_load_image(
&mut self,
url: &str,
profile: ImageRequestProfile,
) -> Option<Arc<[u8]>> {
let key = image_resource_key(url, profile);
if let Some(entry) = self.entries.get(&key) {
match entry {
CachedResource::Bytes(bytes) => return Some(Arc::clone(bytes)),
CachedResource::Missing(at) if at.elapsed() < MISSING_RESOURCE_RETRY_AFTER => {
return None;
}
CachedResource::Missing(_) => {}
}
}
if !self.sync_loading_enabled {
return None;
}
self.remove(&key);
// The private profile key is cache identity only and must never escape
// into a network loader.
let loaded = self
.loader
.load(&network_resource_url(url))
.map(Arc::<[u8]>::from);
match loaded {
Some(bytes) => {
self.insert_bytes(key, Arc::clone(&bytes));
Some(bytes)
}
None => {
self.insert_missing(key);
None
}
}
@@ -382,6 +512,7 @@ impl RenderResourceCache {
SelectedImage {
resolved_url: value.resolved_url,
density: 1.0,
profile: ImageRequestProfile::NoCorsInclude,
},
);
seeded.insert(nid);
@@ -426,11 +557,19 @@ impl RenderResourceCache {
}
}
/// Inspect already-fetched image bytes without inserting them into a resource
/// cache. HTMLImageElement keeps request-profile-specific lifecycle outcomes
/// even though the renderer's ordinary paint cache is URL-keyed.
pub fn image_intrinsic_dimensions(bytes: &[u8]) -> Option<(f32, f32)> {
image_metadata_from_bytes(bytes)
}
/// The exact responsive image candidate chosen during preparation.
#[derive(Clone, Debug, PartialEq)]
pub struct SelectedImage {
pub resolved_url: String,
pub density: f32,
pub profile: ImageRequestProfile,
}
/// CSSOM scrolling metrics for one element box. Non-scroll containers expose
@@ -616,6 +755,7 @@ impl ResolvedScrollState {
/// The DOM must not be mutated while this value is reused.
pub struct PreparedRender {
viewport: (f32, f32),
animation_sample: crate::AnimationSample,
root_font_size: f32,
base_url: Option<String>,
content_size: (f32, f32),
@@ -632,6 +772,51 @@ impl PreparedRender {
self.viewport
}
pub fn animation_sample_time(&self) -> crate::AnimationSampleTime {
self.animation_sample.time
}
pub fn animation_sample(&self) -> crate::AnimationSample {
self.animation_sample
}
/// Whether advancing the document timeline can still change a sampled CSS
/// 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| {
if style.animation_name.is_none()
|| !style.animation_has_render_effect
|| style.animation_timing.play_state == crate::AnimationPlayState::Paused
|| style.animation_timing.duration_ms <= 0.0
|| style.animation_timing.iteration_count <= 0.0
{
return false;
}
let end = style.animation_timing.delay_ms
+ style.animation_timing.duration_ms * style.animation_timing.iteration_count;
end.is_infinite() || style.animation_local_time_ms < end.max(0.0)
})
}
/// Advance a frame whose animation cascade is already time-invariant.
/// This preserves retained layout for static pages and completed finite
/// animations. Backward seeks are never eligible because they can re-enter
/// an earlier active interval or undo forwards fill.
pub fn advance_inactive_animation_sample_time(
&mut self,
sample: crate::AnimationSampleTime,
) -> bool {
if self.animation_sample.mode != crate::AnimationSampleMode::DocumentTime
|| sample.milliseconds < self.animation_sample.time.milliseconds
|| self.has_active_css_animations()
{
return false;
}
self.animation_sample.time = sample;
true
}
pub fn base_url(&self) -> Option<&str> {
self.base_url.as_deref()
}
@@ -1562,9 +1747,31 @@ pub fn paint_dom_scrolled(
viewport: (f32, f32),
base_url: Option<&str>,
scroll: (f32, f32),
) -> Option<Pixmap> {
paint_dom_scrolled_at_animation_time(
tree,
viewport,
base_url,
scroll,
crate::AnimationSampleTime::default(),
)
}
pub fn paint_dom_scrolled_at_animation_time(
tree: &DomTree,
viewport: (f32, f32),
base_url: Option<&str>,
scroll: (f32, f32),
animation_sample_time: crate::AnimationSampleTime,
) -> Option<Pixmap> {
let mut resources = RenderResourceCache::default();
let mut prepared = prepare_dom(tree, viewport, base_url, &mut resources)?;
let mut prepared = prepare_dom_at_animation_time(
tree,
viewport,
base_url,
&mut resources,
animation_sample_time,
)?;
paint_prepared(tree, &mut prepared, &mut resources, scroll)
}
@@ -1576,7 +1783,30 @@ pub fn prepare_dom(
base_url: Option<&str>,
resources: &mut RenderResourceCache,
) -> Option<PreparedRender> {
prepare_dom_with_dynamic_fonts(tree, viewport, base_url, resources, &[])
prepare_dom_at_animation_time(
tree,
viewport,
base_url,
resources,
crate::AnimationSampleTime::default(),
)
}
pub fn prepare_dom_at_animation_time(
tree: &DomTree,
viewport: (f32, f32),
base_url: Option<&str>,
resources: &mut RenderResourceCache,
animation_sample_time: crate::AnimationSampleTime,
) -> Option<PreparedRender> {
prepare_dom_with_dynamic_fonts_at_animation_time(
tree,
viewport,
base_url,
resources,
&[],
animation_sample_time,
)
}
/// Prepare a DOM with the document's script-created `FontFace` registrations.
@@ -1589,15 +1819,34 @@ pub fn prepare_dom_with_dynamic_fonts(
base_url: Option<&str>,
resources: &mut RenderResourceCache,
dynamic_fonts: &[DynamicFontFace],
) -> Option<PreparedRender> {
prepare_dom_with_dynamic_fonts_at_animation_time(
tree,
viewport,
base_url,
resources,
dynamic_fonts,
crate::AnimationSampleTime::default(),
)
}
pub fn prepare_dom_with_dynamic_fonts_at_animation_time(
tree: &DomTree,
viewport: (f32, f32),
base_url: Option<&str>,
resources: &mut RenderResourceCache,
dynamic_fonts: &[DynamicFontFace],
animation_sample_time: crate::AnimationSampleTime,
) -> Option<PreparedRender> {
let mut stylesheet_cache = crate::css::StylesheetCache::default();
prepare_dom_with_dynamic_fonts_and_stylesheet_cache(
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time(
tree,
viewport,
base_url,
resources,
dynamic_fonts,
&mut stylesheet_cache,
animation_sample_time,
)
}
@@ -1611,6 +1860,52 @@ pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache(
resources: &mut RenderResourceCache,
dynamic_fonts: &[DynamicFontFace],
stylesheet_cache: &mut crate::css::StylesheetCache,
) -> Option<PreparedRender> {
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time(
tree,
viewport,
base_url,
resources,
dynamic_fonts,
stylesheet_cache,
crate::AnimationSampleTime::default(),
)
}
pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time(
tree: &DomTree,
viewport: (f32, f32),
base_url: Option<&str>,
resources: &mut RenderResourceCache,
dynamic_fonts: &[DynamicFontFace],
stylesheet_cache: &mut crate::css::StylesheetCache,
animation_sample_time: crate::AnimationSampleTime,
) -> Option<PreparedRender> {
let mut animation_timeline = crate::AnimationTimelineState::default();
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
tree,
viewport,
base_url,
resources,
dynamic_fonts,
stylesheet_cache,
crate::AnimationSample {
time: animation_sample_time,
mode: crate::AnimationSampleMode::DocumentTime,
},
&mut animation_timeline,
)
}
pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
tree: &DomTree,
viewport: (f32, f32),
base_url: Option<&str>,
resources: &mut RenderResourceCache,
dynamic_fonts: &[DynamicFontFace],
stylesheet_cache: &mut crate::css::StylesheetCache,
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> Option<PreparedRender> {
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
tree,
@@ -1620,6 +1915,8 @@ pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache(
dynamic_fonts,
stylesheet_cache,
None,
animation_sample,
animation_timeline,
)
}
@@ -1663,9 +1960,76 @@ pub fn prepare_dom_with_retained_styles(
resources: &mut RenderResourceCache,
dynamic_fonts: &[DynamicFontFace],
stylesheet_cache: &mut crate::css::StylesheetCache,
mut previous: PreparedRender,
previous: PreparedRender,
mutations: &[crate::dom::RetainedStyleMutation],
) -> Option<PreparedRender> {
prepare_dom_with_retained_styles_at_animation_time(
tree,
viewport,
base_url,
resources,
dynamic_fonts,
stylesheet_cache,
previous,
mutations,
crate::AnimationSampleTime::default(),
)
}
pub fn prepare_dom_with_retained_styles_at_animation_time(
tree: &DomTree,
viewport: (f32, f32),
base_url: Option<&str>,
resources: &mut RenderResourceCache,
dynamic_fonts: &[DynamicFontFace],
stylesheet_cache: &mut crate::css::StylesheetCache,
previous: PreparedRender,
mutations: &[crate::dom::RetainedStyleMutation],
animation_sample_time: crate::AnimationSampleTime,
) -> Option<PreparedRender> {
let mut animation_timeline = crate::AnimationTimelineState::default();
prepare_dom_with_retained_styles_with_animation_state(
tree,
viewport,
base_url,
resources,
dynamic_fonts,
stylesheet_cache,
previous,
mutations,
crate::AnimationSample {
time: animation_sample_time,
mode: crate::AnimationSampleMode::DocumentTime,
},
&mut animation_timeline,
)
}
pub fn prepare_dom_with_retained_styles_with_animation_state(
tree: &DomTree,
viewport: (f32, f32),
base_url: Option<&str>,
resources: &mut RenderResourceCache,
dynamic_fonts: &[DynamicFontFace],
stylesheet_cache: &mut crate::css::StylesheetCache,
mut previous: PreparedRender,
mutations: &[crate::dom::RetainedStyleMutation],
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> Option<PreparedRender> {
if previous.animation_sample != animation_sample {
drop(previous);
return prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
tree,
viewport,
base_url,
resources,
dynamic_fonts,
stylesheet_cache,
animation_sample,
animation_timeline,
);
}
let retained = RetainedStyleMaps {
styles: std::mem::take(&mut previous.layout.styles),
custom_properties: std::mem::take(&mut previous.layout.custom_properties),
@@ -1679,6 +2043,8 @@ pub fn prepare_dom_with_retained_styles(
dynamic_fonts,
stylesheet_cache,
Some((retained, mutations)),
animation_sample,
animation_timeline,
)
}
@@ -1690,6 +2056,8 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
dynamic_fonts: &[DynamicFontFace],
stylesheet_cache: &mut crate::css::StylesheetCache,
retained: Option<(RetainedStyleMaps, &[crate::dom::RetainedStyleMutation])>,
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> Option<PreparedRender> {
if !viewport.0.is_finite() || !viewport.1.is_finite() || viewport.0 <= 0.0 || viewport.1 <= 0.0
{
@@ -1731,7 +2099,7 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
svg_font_database()
};
let mut laid = match retained {
Some((retained, mutations)) => layout_dom_with_web_fonts_and_retained_styles(
Some((retained, mutations)) => layout_dom_with_web_fonts_and_retained_styles_with_animation_state(
tree,
viewport,
&intrinsic,
@@ -1739,13 +2107,17 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
stylesheet_cache,
retained,
mutations,
animation_sample,
animation_timeline,
),
None => layout_dom_with_web_fonts_and_stylesheet_cache(
None => layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_state(
tree,
viewport,
&intrinsic,
&fonts,
stylesheet_cache,
animation_sample,
animation_timeline,
),
};
// `content:url(...)` is computed by the author cascade, whereas ordinary
@@ -1767,12 +2139,14 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
{
resources.content_image_layout_retries += 1;
}
laid = layout_dom_with_web_fonts_and_stylesheet_cache(
laid = layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_state(
tree,
viewport,
&intrinsic,
&fonts,
stylesheet_cache,
animation_sample,
animation_timeline,
);
}
let content_size = laid.scrolling_content_size(tree, viewport);
@@ -1788,6 +2162,7 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
.unwrap_or(16.0);
Some(PreparedRender {
viewport,
animation_sample,
root_font_size,
base_url: base_url.map(str::to_string),
content_size,
@@ -2945,6 +3320,7 @@ fn paint_laid_dom_scrolled(
&mut pixmap,
image_cache,
None,
None,
background.clip_radii,
background_mask,
);
@@ -3048,6 +3424,7 @@ fn paint_laid_dom_scrolled(
style.object_fit,
&mut pixmap,
image_cache,
Some(source.profile),
None,
radius,
element_clip_mask,
@@ -3676,6 +4053,7 @@ fn paint_inline_fragment_decorations(
pixmap,
image_cache,
None,
None,
background.clip_radii,
background_mask.as_ref(),
);
@@ -4704,6 +5082,23 @@ pub fn screenshot_png_scrolled(
paint_dom_scrolled(tree, viewport, base_url, scroll).and_then(|pixmap| pixmap.encode_png().ok())
}
pub fn screenshot_png_scrolled_at_animation_time(
tree: &DomTree,
viewport: (f32, f32),
base_url: Option<&str>,
scroll: (f32, f32),
animation_sample_time: crate::AnimationSampleTime,
) -> Option<Vec<u8>> {
paint_dom_scrolled_at_animation_time(
tree,
viewport,
base_url,
scroll,
animation_sample_time,
)
.and_then(|pixmap| pixmap.encode_png().ok())
}
/// PNG convenience wrapper for a retained resource-aware layout.
pub fn screenshot_prepared(
tree: &DomTree,
@@ -5181,6 +5576,19 @@ fn fetch_bytes(
cache.get_or_load(&resolved)
}
fn fetch_profiled_image_bytes(
src: &str,
base_url: Option<&str>,
cache: &mut RenderResourceCache,
profile: ImageRequestProfile,
) -> Option<Arc<[u8]>> {
if src.starts_with("data:") {
return fetch_bytes(src, base_url, cache);
}
let resolved = resolve_resource_url(src, base_url)?;
cache.get_or_load_image(&resolved, profile)
}
fn resolve_resource_url(src: &str, base_url: Option<&str>) -> Option<String> {
if src.starts_with("data:") {
return Some(src.to_string());
@@ -6818,6 +7226,7 @@ fn paint_in_flow_generated_box(
pixmap,
image_cache,
None,
None,
background.clip_radii,
background_mask.as_ref(),
);
@@ -7051,6 +7460,7 @@ fn paint_positioned_pseudo(
pixmap,
image_cache,
None,
None,
background.clip_radii,
background_mask.as_ref(),
);
@@ -7143,14 +7553,16 @@ fn collect_image_intrinsics(
continue;
};
let resolved_url = resolve_resource_url(&url, base_url).unwrap_or(url);
let profile = image_request_profile(tree, nid);
selected.insert(
nid,
SelectedImage {
resolved_url: resolved_url.clone(),
density,
profile,
},
);
let Some(bytes) = fetch_bytes(&resolved_url, None, cache) else {
let Some(bytes) = fetch_profiled_image_bytes(&resolved_url, None, cache, profile) else {
continue;
};
let dimensions = image_dimensions(&bytes)
@@ -7208,6 +7620,7 @@ fn collect_content_image_intrinsics(
SelectedImage {
resolved_url: resolved_url.clone(),
density: 1.0,
profile: ImageRequestProfile::NoCorsInclude,
},
);
// CSS content replaces the element's ordinary source. Remove the src
@@ -7528,6 +7941,7 @@ fn paint_image(
object_fit: crate::ObjectFit,
pixmap: &mut Pixmap,
cache: &mut RenderResourceCache,
profile: Option<ImageRequestProfile>,
transform: Option<crate::Affine2>,
clip_radius: crate::ResolvedBorderRadii,
extra_clip: Option<&tiny_skia::Mask>,
@@ -7541,7 +7955,11 @@ fn paint_image(
if !rect_intersects_paint_surface(visible_rect, pixmap, 1.0) {
return false;
}
let Some(bytes) = fetch_bytes(src, base_url, cache) else {
let bytes = match profile {
Some(profile) => fetch_profiled_image_bytes(src, base_url, cache, profile),
None => fetch_bytes(src, base_url, cache),
};
let Some(bytes) = bytes else {
return false;
};
let svg = is_svg(&bytes);
@@ -8746,6 +9164,56 @@ mod tests {
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
}
#[test]
fn html_image_profiles_keep_intrinsic_geometry_and_paint_separate() {
let network_url = "https://assets.test/shared.svg";
let url = "https://assets.test/shared.svg#icon";
let tree = parse_html(&format!(
r#"<html style="margin:0"><body style="margin:0">
<img id="plain" src="{url}" style="display:block">
<img id="anonymous" crossorigin="anonymous" src="{url}" style="display:block">
<img id="credentialed" crossorigin="use-credentials" alt="" src="{url}"
style="display:block;width:20px;height:10px">
</body></html>"#
));
let mut resources = RenderResourceCache::with_loader(|_url: &str| {
panic!("seeded profile resources must not reach the loader")
});
resources.seed_image(
network_url.to_string(),
ImageRequestProfile::NoCorsInclude,
br##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><rect width="20" height="10" fill="#f00"/></svg>"##.to_vec(),
);
resources.seed_image(
network_url.to_string(),
ImageRequestProfile::CorsSameOrigin,
br##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="40" height="30" fill="#0f0"/></svg>"##.to_vec(),
);
resources.seed_image_missing(network_url.to_string(), ImageRequestProfile::CorsInclude);
let mut prepared =
prepare_dom(&tree, (80.0, 60.0), None, &mut resources).expect("profiled layout");
let node = |selector| tree.query_selector(selector).unwrap().unwrap();
let plain = prepared.layout().rects[&node("#plain")];
let anonymous = prepared.layout().rects[&node("#anonymous")];
assert_eq!(prepared.selected_image(node("#plain")).unwrap().resolved_url, url);
assert_eq!((plain.width, plain.height), (20.0, 10.0));
assert_eq!((anonymous.width, anonymous.height), (40.0, 30.0));
let pixmap = paint_prepared(&tree, &mut prepared, &mut resources, (0.0, 0.0))
.expect("profiled paint");
let red = pixmap.pixel(5, 5).unwrap();
assert!(red.red() > 240 && red.green() < 20, "{red:?}");
let green = pixmap.pixel(5, 20).unwrap();
assert!(green.green() > 240 && green.red() < 20, "{green:?}");
let failed = pixmap.pixel(5, 45).unwrap();
assert_eq!(
(failed.red(), failed.green(), failed.blue(), failed.alpha()),
(255, 255, 255, 255),
"failed credentialed image must not paint bytes from another profile"
);
}
#[test]
fn image_accept_advertises_exactly_decodable_mime_types() {
assert!(!IMAGE_ACCEPT.contains('*'));
@@ -9075,6 +9543,7 @@ mod tests {
Some(&SelectedImage {
resolved_url: "https://assets.test/hero.svg".to_string(),
density: 2.0,
profile: ImageRequestProfile::NoCorsInclude,
})
);
let hero_rect = prepared.layout().rects.get(&hero).expect("hero rect");
@@ -12572,6 +13041,7 @@ mod tests {
&mut pixmap,
&mut resources,
None,
None,
crate::ResolvedBorderRadii::default(),
None,
));
@@ -12612,4 +13082,49 @@ mod tests {
let sticky = pixmap.pixel(75, 15).expect("sticky pixel");
assert!(sticky.blue() > 240 && sticky.red() < 20);
}
#[test]
fn prepared_render_samples_explicit_animation_time_and_reports_live_damage() {
let tree = parse_html(
r#"<style>
@keyframes dismiss {
from { opacity:1; visibility:visible }
to { opacity:0; visibility:hidden }
}
#overlay { opacity:1; animation:dismiss 600ms linear forwards }
</style><div id="overlay" style="width:20px;height:20px;background:red"></div>"#,
);
let overlay = tree.query_selector("#overlay").unwrap().unwrap();
let mut resources = RenderResourceCache::with_loader(|_url: &str| None);
let mut stylesheets = crate::css::StylesheetCache::default();
let at_zero = prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time(
&tree,
(40.0, 40.0),
None,
&mut resources,
&[],
&mut stylesheets,
crate::AnimationSampleTime { milliseconds: 0.0 },
)
.expect("T=0 render");
assert_eq!(at_zero.animation_sample_time().milliseconds, 0.0);
assert_eq!(at_zero.layout.styles[&overlay].opacity, Some(1.0));
assert_ne!(at_zero.layout.styles[&overlay].visibility_hidden, Some(true));
assert!(at_zero.has_active_css_animations());
let at_end = prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time(
&tree,
(40.0, 40.0),
None,
&mut resources,
&[],
&mut stylesheets,
crate::AnimationSampleTime { milliseconds: 600.0 },
)
.expect("animation-end render");
assert_eq!(at_end.layout.styles[&overlay].opacity, Some(0.0));
assert_eq!(at_end.layout.styles[&overlay].visibility_hidden, Some(true));
assert!(at_end.layout.styles[&overlay].effectively_invisible);
assert!(!at_end.has_active_css_animations());
}
}