libservo: Add a WebView::take_screenshot() API and use it for reftests (#39583)

This change adds a new API to the `WebView` for capturing screenshots.
This makes it possible to:

 - use the reftest waiting infrastructure via the API
   easily.
 - take more than a single screenshot in one Servo run.
 - take screenshots, but still paint the `WebView` normally prior
   to the moment that the screenshot is ready, instead of preventing
   all non-screenshot-ready paints while taking a screenshot.

In addition, the previous infrastructure, the `wait_for_stable_image`
option is removed completely.

Testing: This change is tested by the passing of the WPT tests,
as they commonly use the screenshot feature.

Signed-off-by: Martin Robinson <mrobinson@igalia.com>
Co-authored-by: Delan Azabani <dazabani@igalia.com>
This commit is contained in:
Martin Robinson 2025-09-30 13:39:47 +02:00 committed by GitHub
parent 6995e60ee1
commit 6ffc0cd482
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 701 additions and 468 deletions

View file

@ -85,7 +85,7 @@
//! See <https://github.com/servo/servo/issues/14704>
use std::borrow::ToOwned;
use std::cell::OnceCell;
use std::cell::{Cell, OnceCell, RefCell};
use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet, VecDeque};
use std::marker::PhantomData;
@ -120,8 +120,9 @@ use constellation_traits::{
EmbedderToConstellationMessage, IFrameLoadInfo, IFrameLoadInfoWithData, IFrameSandboxState,
IFrameSizeMsg, Job, LoadData, LoadOrigin, LogEntry, MessagePortMsg, NavigationHistoryBehavior,
PaintMetricEvent, PortMessageTask, PortTransferInfo, SWManagerMsg, SWManagerSenders,
ScriptToConstellationChan, ScriptToConstellationMessage, ServiceWorkerManagerFactory,
ServiceWorkerMsg, StructuredSerializedData, TraversalDirection, WindowSizeType,
ScreenshotReadinessResponse, ScriptToConstellationChan, ScriptToConstellationMessage,
ServiceWorkerManagerFactory, ServiceWorkerMsg, StructuredSerializedData, TraversalDirection,
WindowSizeType,
};
use crossbeam_channel::{Receiver, Select, Sender, unbounded};
use devtools_traits::{
@ -506,6 +507,11 @@ pub struct Constellation<STF, SWF> {
/// Pending viewport changes for browsing contexts that are not
/// yet known to the constellation.
pending_viewport_changes: HashMap<BrowsingContextId, ViewportDetails>,
/// Pending screenshot readiness requests. These are collected until the screenshot is
/// ready to take place, at which point the Constellation informs the renderer that it
/// can start the process of taking the screenshot.
screenshot_readiness_requests: Vec<ScreenshotReadinessRequest>,
}
/// State needed to construct a constellation.
@ -572,18 +578,6 @@ pub struct InitialConstellationState {
pub async_runtime: Box<dyn AsyncRuntime>,
}
/// When we are running reftests, we save an image to compare against a reference.
/// This enum gives the possible states of preparing such an image.
#[derive(Debug, PartialEq)]
enum ReadyToSave {
NoTopLevelBrowsingContext,
PendingChanges,
DocumentLoading,
EpochMismatch,
PipelineUnknown,
Ready,
}
/// When we are exiting a pipeline, we can either force exiting or not.
/// A normal exit waits for the compositor to update its state before
/// exiting, and delegates layout exit to script. A forced exit does
@ -768,6 +762,7 @@ where
rippy_data,
)),
pending_viewport_changes: Default::default(),
screenshot_readiness_requests: Vec::new(),
};
constellation.run();
@ -1454,14 +1449,6 @@ where
NavigationHistoryBehavior::Push,
);
},
EmbedderToConstellationMessage::IsReadyToSaveImage(pipeline_states) => {
let is_ready = self.handle_is_ready_to_save_image(pipeline_states);
debug!("Ready to save image {:?}.", is_ready);
self.compositor_proxy
.send(CompositorMsg::IsReadyToSaveImageReply(
is_ready == ReadyToSave::Ready,
));
},
// Create a new top level browsing context. Will use response_chan to return
// the browsing context id.
EmbedderToConstellationMessage::NewWebView(url, webview_id, viewport_details) => {
@ -1605,6 +1592,9 @@ where
));
}
},
EmbedderToConstellationMessage::RequestScreenshotReadiness(webview_id) => {
self.handle_request_screenshot_readiness(webview_id)
},
}
}
@ -1864,13 +1854,6 @@ where
ScriptToConstellationMessage::SetDocumentState(state) => {
self.document_states.insert(source_pipeline_id, state);
},
ScriptToConstellationMessage::SetLayoutEpoch(epoch, response_sender) => {
if let Some(pipeline) = self.pipelines.get_mut(&source_pipeline_id) {
pipeline.layout_epoch = epoch;
}
response_sender.send(true).unwrap_or_default();
},
ScriptToConstellationMessage::LogEntry(thread_name, entry) => {
self.handle_log_entry(Some(webview_id), thread_name, entry);
},
@ -2025,6 +2008,9 @@ where
}
}
},
ScriptToConstellationMessage::RespondToScreenshotReadinessRequest(response) => {
self.handle_screenshot_readiness_response(source_pipeline_id, response);
},
}
}
@ -3772,6 +3758,8 @@ where
ExitPipelineMode::Normal,
);
}
self.send_screenshot_readiness_requests_to_pipelines();
}
#[servo_tracing::instrument(skip_all)]
@ -3790,19 +3778,7 @@ where
.get(&BrowsingContextId::from(webview_id))
.map(|ctx| ctx.pipeline_id == pipeline_id)
.unwrap_or(false);
if pipeline_is_top_level_pipeline {
// Is there any pending pipeline that will replace the current top level pipeline
let current_top_level_pipeline_will_be_replaced = self
.pending_changes
.iter()
.any(|change| change.browsing_context_id == webview_id);
if !current_top_level_pipeline_will_be_replaced {
// Notify embedder and compositor top level document finished loading.
self.compositor_proxy
.send(CompositorMsg::LoadComplete(webview_id));
}
} else {
if !pipeline_is_top_level_pipeline {
self.handle_subframe_loaded(pipeline_id);
}
}
@ -5037,44 +5013,48 @@ where
debug!("{}: Document ready to activate", pipeline_id);
// Find the pending change whose new pipeline id is pipeline_id.
let pending_index = self
let Some(pending_index) = self
.pending_changes
.iter()
.rposition(|change| change.new_pipeline_id == pipeline_id);
.rposition(|change| change.new_pipeline_id == pipeline_id)
else {
return;
};
// If it is found, remove it from the pending changes, and make it
// the active document of its frame.
if let Some(pending_index) = pending_index {
let change = self.pending_changes.swap_remove(pending_index);
// Notify the parent (if there is one).
let parent_pipeline_id = match change.new_browsing_context_info {
// This will be a new browsing context.
Some(ref info) => info.parent_pipeline_id,
// This is an existing browsing context.
None => match self.browsing_contexts.get(&change.browsing_context_id) {
Some(ctx) => ctx.parent_pipeline_id,
None => {
return warn!(
"{}: Activated document after closure of {}",
change.new_pipeline_id, change.browsing_context_id,
);
},
},
};
if let Some(parent_pipeline_id) = parent_pipeline_id {
if let Some(parent_pipeline) = self.pipelines.get(&parent_pipeline_id) {
let msg = ScriptThreadMessage::UpdatePipelineId(
parent_pipeline_id,
change.browsing_context_id,
change.webview_id,
pipeline_id,
UpdatePipelineIdReason::Navigation,
let change = self.pending_changes.swap_remove(pending_index);
self.send_screenshot_readiness_requests_to_pipelines();
// Notify the parent (if there is one).
let parent_pipeline_id = match change.new_browsing_context_info {
// This will be a new browsing context.
Some(ref info) => info.parent_pipeline_id,
// This is an existing browsing context.
None => match self.browsing_contexts.get(&change.browsing_context_id) {
Some(ctx) => ctx.parent_pipeline_id,
None => {
return warn!(
"{}: Activated document after closure of {}",
change.new_pipeline_id, change.browsing_context_id,
);
let _ = parent_pipeline.event_loop.send(msg);
}
},
},
};
if let Some(parent_pipeline_id) = parent_pipeline_id {
if let Some(parent_pipeline) = self.pipelines.get(&parent_pipeline_id) {
let msg = ScriptThreadMessage::UpdatePipelineId(
parent_pipeline_id,
change.browsing_context_id,
change.webview_id,
pipeline_id,
UpdatePipelineIdReason::Navigation,
);
let _ = parent_pipeline.event_loop.send(msg);
}
self.change_session_history(change);
}
self.change_session_history(change);
}
/// Called when the window is resized.
@ -5101,88 +5081,108 @@ where
self.switch_fullscreen_mode(browsing_context_id);
}
/// Checks the state of all script and layout pipelines to see if they are idle
/// and compares the current layout state to what the compositor has. This is used
/// to check if the output image is "stable" and can be written as a screenshot
/// for reftests.
/// Since this function is only used in reftests, we do not harden it against panic.
#[servo_tracing::instrument(skip_all)]
fn handle_is_ready_to_save_image(
&mut self,
pipeline_states: FxHashMap<PipelineId, Epoch>,
) -> ReadyToSave {
// Note that this function can panic, due to ipc-channel creation
// failure. Avoiding this panic would require a mechanism for dealing
// with low-resource scenarios.
//
// If there is no focus browsing context yet, the initial page has
// not loaded, so there is nothing to save yet.
let Some(webview_id) = self.webviews.focused_webview().map(|(id, _)| id) else {
return ReadyToSave::NoTopLevelBrowsingContext;
};
fn handle_request_screenshot_readiness(&mut self, webview_id: WebViewId) {
self.screenshot_readiness_requests
.push(ScreenshotReadinessRequest {
webview_id,
pipeline_states: Default::default(),
state: Default::default(),
});
self.send_screenshot_readiness_requests_to_pipelines();
}
fn send_screenshot_readiness_requests_to_pipelines(&mut self) {
// If there are pending loads, wait for those to complete.
if !self.pending_changes.is_empty() {
return ReadyToSave::PendingChanges;
return;
}
// Step through the fully active browsing contexts, checking that the script thread is idle,
// and that the current epoch of the layout matches what the compositor has painted. If all
// these conditions are met, then the output image should not change and a reftest
// screenshot can safely be written.
for browsing_context in self.fully_active_browsing_contexts_iter(webview_id) {
let pipeline_id = browsing_context.pipeline_id;
trace!(
"{}: Checking readiness of {}",
browsing_context.id, pipeline_id
);
let pipeline = match self.pipelines.get(&pipeline_id) {
None => {
warn!("{}: Screenshot while closing", pipeline_id);
continue;
},
Some(pipeline) => pipeline,
};
// See if this pipeline has reached idle script state yet.
match self.document_states.get(&browsing_context.pipeline_id) {
Some(&DocumentState::Idle) => {},
Some(&DocumentState::Pending) | None => {
return ReadyToSave::DocumentLoading;
},
for screenshot_request in &self.screenshot_readiness_requests {
// Ignore this request if it is not pending.
if screenshot_request.state.get() != ScreenshotRequestState::Pending {
return;
}
// Check the visible rectangle for this pipeline. If the constellation has received a
// size for the pipeline, then its painting should be up to date.
//
// If the rectangle for this pipeline is zero sized, it will
// never be painted. In this case, don't query the layout
// thread as it won't contribute to the final output image.
if browsing_context.viewport_details.size == Size2D::zero() {
continue;
}
// Get the epoch that the compositor has drawn for this pipeline and then check if the
// last laid out epoch matches what the compositor has drawn. If they match (and script
// is idle) then this pipeline won't change again and can be considered stable.
let compositor_epoch = pipeline_states.get(&browsing_context.pipeline_id);
match compositor_epoch {
Some(compositor_epoch) => {
if pipeline.layout_epoch != *compositor_epoch {
return ReadyToSave::EpochMismatch;
*screenshot_request.pipeline_states.borrow_mut() = self
.fully_active_browsing_contexts_iter(screenshot_request.webview_id)
.filter_map(|browsing_context| {
let pipeline_id = browsing_context.pipeline_id;
let Some(pipeline) = self.pipelines.get(&pipeline_id) else {
// This can happen while Servo is shutting down, so just ignore it for now.
return None;
};
// If the rectangle for this BrowsingContext is zero, it will never be
// painted. In this case, don't query screenshot readiness as it won't
// contribute to the final output image.
if browsing_context.viewport_details.size == Size2D::zero() {
return None;
}
},
None => {
// The compositor doesn't know about this pipeline yet.
// Assume it hasn't rendered yet.
return ReadyToSave::PipelineUnknown;
},
}
let _ = pipeline
.event_loop
.send(ScriptThreadMessage::RequestScreenshotReadiness(pipeline_id));
Some((pipeline_id, None))
})
.collect();
screenshot_request
.state
.set(ScreenshotRequestState::WaitingOnScript);
}
}
#[servo_tracing::instrument(skip_all)]
fn handle_screenshot_readiness_response(
&mut self,
updated_pipeline_id: PipelineId,
response: ScreenshotReadinessResponse,
) {
if self.screenshot_readiness_requests.is_empty() {
return;
}
// All script threads are idle and layout epochs match compositor, so output image!
ReadyToSave::Ready
self.screenshot_readiness_requests
.retain(|screenshot_request| {
if screenshot_request.state.get() != ScreenshotRequestState::WaitingOnScript {
return true;
}
let mut has_pending_pipeline = false;
let mut pipeline_states = screenshot_request.pipeline_states.borrow_mut();
pipeline_states.retain(|pipeline_id, state| {
if *pipeline_id != updated_pipeline_id {
has_pending_pipeline |= state.is_none();
return true;
}
match response {
ScreenshotReadinessResponse::Ready(epoch) => {
*state = Some(epoch);
true
},
ScreenshotReadinessResponse::NoLongerActive => false,
}
});
if has_pending_pipeline {
return true;
}
let pipelines_and_epochs = pipeline_states
.iter()
.map(|(pipeline_id, epoch)| {
(
*pipeline_id,
epoch.expect("Should have an epoch when pipeline is ready."),
)
})
.collect();
self.compositor_proxy
.send(CompositorMsg::ScreenshotReadinessReponse(
screenshot_request.webview_id,
pipelines_and_epochs,
));
false
});
}
/// Get the current activity of a pipeline.
@ -5536,6 +5536,12 @@ where
// Inform script, compositor that this pipeline has exited.
pipeline.send_exit_message_to_script(dbc);
self.send_screenshot_readiness_requests_to_pipelines();
self.handle_screenshot_readiness_response(
pipeline_id,
ScreenshotReadinessResponse::NoLongerActive,
);
debug!("{}: Closed", pipeline_id);
}
@ -5709,3 +5715,27 @@ where
CanvasPaintThread::start(self.compositor_proxy.cross_process_compositor_api.clone())
}
}
/// When a [`ScreenshotReadinessRequest`] is received from the renderer, the [`Constellation`]
/// go through a variety of states to process them. This data structure represents those states.
#[derive(Clone, Copy, Default, PartialEq)]
enum ScreenshotRequestState {
/// The [`Constellation`] has received the [`ScreenshotReadinessRequest`], but has not yet
/// forwarded it to the [`Pipeline`]'s of the requests's WebView. This is likely because there
/// are still pending navigation changes in the [`Constellation`]. Once those changes are resolved
/// the request will be forwarded to the [`Pipeline`]s.
#[default]
Pending,
/// The [`Constellation`] has forwarded the [`ScreenshotReadinessRequest`] to the [`Pipeline`]s of
/// the corresponding `WebView`. The [`Pipeline`]s are waiting for a variety of things to happen in
/// order to report what appropriate display list epoch is for the screenshot. Once they all report
/// back, the [`Constellation`] considers that the request is handled, and the renderer is responsible
/// for waiting to take the screenshot.
WaitingOnScript,
}
struct ScreenshotReadinessRequest {
webview_id: WebViewId,
state: Cell<ScreenshotRequestState>,
pipeline_states: RefCell<FxHashMap<PipelineId, Option<Epoch>>>,
}