perf(capture): add SceneLens GPU streams and retained scene reuse

Use persistent privacy-filtered capture targets and client-owned GPU buffers with fence completion, backpressure, negotiated frame rates and shared-memory fallback.

Invalidate output views on scene changes while preserving inactive-window callbacks and capture revocation. Include OBS memory-mask negotiation and native pixel, transport and recording regressions.
This commit is contained in:
Samuel Onoja committed 2026-10-24 19:58:54 +01:00
1 parent f49f62583d
commit 533cd74c94
40 files changed
+4698 -698

No files matched your search

+4 -1
View File
@@ -24,7 +24,7 @@ jobs:
sudo apt-get install -y build-essential pkg-config dbus-daemon libwayland-dev \ sudo apt-get install -y build-essential pkg-config dbus-daemon libwayland-dev \
libxkbcommon-dev xkb-data libinput-dev libudev-dev libseat-dev libgbm-dev \ libxkbcommon-dev xkb-data libinput-dev libudev-dev libseat-dev libgbm-dev \
python3-gi python3-pil libegl-dev libegl-mesa0 libgl1-mesa-dri mesa-vulkan-drivers libdrm-dev libfontconfig1-dev libfreetype-dev libexpat1-dev xauth \ python3-gi python3-pil libegl-dev libegl-mesa0 libgl1-mesa-dri mesa-vulkan-drivers libdrm-dev libfontconfig1-dev libfreetype-dev libexpat1-dev xauth \
clang libclang-dev libpipewire-0.3-dev libspa-0.2-dev libglib2.0-dev libgstreamer1.0-dev libpam-modules clang libclang-dev libpipewire-0.3-dev libspa-0.2-dev libglib2.0-dev libgstreamer1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good libpam-modules
- name: Create private test runtime directory - name: Create private test runtime directory
run: install -d -m 0700 "$XDG_RUNTIME_DIR" run: install -d -m 0700 "$XDG_RUNTIME_DIR"
- name: Install Rust - name: Install Rust
@@ -60,6 +60,9 @@ jobs:
cargo +1.95.0 test --locked -p ferese state::capture_privacy::tests -- --ignored --nocapture cargo +1.95.0 test --locked -p ferese state::capture_privacy::tests -- --ignored --nocapture
cargo +1.95.0 test --locked -p ferese backends::direct::capture::tests::capture_recomposes_cursor_without_touching_the_display_target -- --ignored --nocapture cargo +1.95.0 test --locked -p ferese backends::direct::capture::tests::capture_recomposes_cursor_without_touching_the_display_target -- --ignored --nocapture
cargo +1.95.0 test --locked -p ferese winit::tests::capture_failure_restores_display_target_and_subsequent_frames_render -- --ignored --nocapture cargo +1.95.0 test --locked -p ferese winit::tests::capture_failure_restores_display_target_and_subsequent_frames_render -- --ignored --nocapture
cargo +1.95.0 test --locked -p ferese render::capture::stream_gpu_tests -- --ignored --nocapture
- name: Preserve static recording duration
run: cargo +1.95.0 test --locked -p xdg-desktop-portal-ferese --bin ferese-record recorder::tests::encoder_preserves_the_duration_of_a_static_capture -- --ignored --nocapture
- name: Share desktop binaries with native checks - name: Share desktop binaries with native checks
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
Generated
+2
View File
@@ -1903,6 +1903,7 @@ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"wayland-backend", "wayland-backend",
"wayland-client", "wayland-client",
"wayland-protocols",
"wayland-protocols-wlr", "wayland-protocols-wlr",
"wayland-scanner", "wayland-scanner",
"wayland-server", "wayland-server",
@@ -8259,6 +8260,7 @@ dependencies = [
"ferese-theme", "ferese-theme",
"ferese-theme-client", "ferese-theme-client",
"futures-util", "futures-util",
"gbm",
"gstreamer", "gstreamer",
"image", "image",
"jiff", "jiff",
+40 -5
View File
@@ -177,6 +177,24 @@ impl DirectBackendState {
) )
} }
pub(crate) fn clear_stream_capture_targets(&mut self) {
for device in self.devices.values_mut() {
crate::render::clear_stream_capture_targets(&mut device.renderer);
}
}
pub(crate) fn capture_renderer(&mut self, output: &Output) -> Option<&mut GlesRenderer> {
self.devices
.values_mut()
.find(|device| {
device
.outputs
.values()
.any(|candidate| &candidate.output == output)
})
.map(|device| &mut device.renderer)
}
pub(crate) fn capture_window_buffer( pub(crate) fn capture_window_buffer(
&mut self, &mut self,
window: &smithay::desktop::Window, window: &smithay::desktop::Window,
@@ -295,13 +313,13 @@ struct DirectOutput {
identity: String, identity: String,
mirror_source: Option<String>, mirror_source: Option<String>,
mirror_texture: Option<GlesTexture>, mirror_texture: Option<GlesTexture>,
mirror_capture_texture: Option<GlesTexture>, mirror_capture_texture: [Option<capture::CaptureTarget>; 2],
mirror_canvas: Option<Output>, mirror_canvas: Option<Output>,
mirror_id: smithay::backend::renderer::element::Id, mirror_id: smithay::backend::renderer::element::Id,
mirror_commit: CommitCounter, mirror_commit: CommitCounter,
surface: OutputCompositor, surface: OutputCompositor,
primary_commit: Option<CommitCounter>, primary_commit: Option<CommitCounter>,
capture_texture: Option<GlesTexture>, capture_texture: [Option<capture::CaptureTarget>; 2],
render_metrics: RenderMetrics, render_metrics: RenderMetrics,
diagnostics: Diagnostics, diagnostics: Diagnostics,
dmabuf_feedback: feedback::OutputFeedback, dmabuf_feedback: feedback::OutputFeedback,
@@ -452,7 +470,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
output.surface.reset_buffers(); output.surface.reset_buffers();
output.dmabuf_feedback.invalidate(); output.dmabuf_feedback.invalidate();
output.primary_commit = None; output.primary_commit = None;
output.capture_texture = None; output.capture_texture = Default::default();
output.frame_pending = false; output.frame_pending = false;
output.power.suspended(); output.power.suspended();
} }
@@ -1226,16 +1244,33 @@ pub(crate) fn wake_locked_outputs(state: &mut Ferese) {
#[track_caller] #[track_caller]
pub fn render_all(state: &mut Ferese) { pub fn render_all(state: &mut Ferese) {
for output in state.space.outputs() {
capture::changes::content_changed(output);
}
redraw(state, None, std::panic::Location::caller()); redraw(state, None, std::panic::Location::caller());
} }
#[track_caller] #[track_caller]
pub(crate) fn render_on(state: &mut Ferese, outputs: &[Output]) { pub(crate) fn render_on(state: &mut Ferese, outputs: &[Output]) {
for output in outputs {
capture::changes::content_changed(output);
}
redraw(state, Some(outputs), std::panic::Location::caller());
}
/// Consumer demand schedules a frame without invalidating retained scene pixels.
#[track_caller]
pub(crate) fn schedule_capture(state: &mut Ferese, outputs: &[Output]) {
redraw(state, Some(outputs), std::panic::Location::caller()); redraw(state, Some(outputs), std::panic::Location::caller());
} }
#[track_caller] #[track_caller]
pub(crate) fn render_cursor(state: &mut Ferese) { pub(crate) fn render_cursor(state: &mut Ferese) {
// Include nested outputs and both sides of an output crossing. This does not
// invalidate the content revision used by cursor-hidden captures.
for output in state.space.outputs() {
capture::changes::cursor_changed(output);
}
let outputs = cursor_affected_outputs(state); let outputs = cursor_affected_outputs(state);
redraw(state, Some(&outputs), std::panic::Location::caller()); redraw(state, Some(&outputs), std::panic::Location::caller());
} }
@@ -2603,13 +2638,13 @@ fn create_direct_output(
identity, identity,
mirror_source, mirror_source,
mirror_texture: None, mirror_texture: None,
mirror_capture_texture: None, mirror_capture_texture: Default::default(),
mirror_canvas: None, mirror_canvas: None,
mirror_id: smithay::backend::renderer::element::Id::new(), mirror_id: smithay::backend::renderer::element::Id::new(),
mirror_commit: CommitCounter::default(), mirror_commit: CommitCounter::default(),
surface, surface,
primary_commit: None, primary_commit: None,
capture_texture: None, capture_texture: Default::default(),
render_metrics, render_metrics,
diagnostics, diagnostics,
dmabuf_feedback, dmabuf_feedback,
+558 -33
View File
@@ -1,4 +1,5 @@
use smithay::backend::allocator::Fourcc; use smithay::backend::allocator::Fourcc;
use smithay::backend::renderer::damage::{Error as DamageError, OutputDamageTracker};
use smithay::backend::renderer::element::RenderElement; use smithay::backend::renderer::element::RenderElement;
use smithay::backend::renderer::gles::{GlesError, GlesRenderer, GlesTarget, GlesTexture}; use smithay::backend::renderer::gles::{GlesError, GlesRenderer, GlesTarget, GlesTexture};
use smithay::backend::renderer::{Bind, Offscreen, Texture}; use smithay::backend::renderer::{Bind, Offscreen, Texture};
@@ -13,6 +14,42 @@ use crate::state::FrameScene;
const CACHE_IDLE: Duration = Duration::from_secs(2); const CACHE_IDLE: Duration = Duration::from_secs(2);
pub(crate) mod changes;
pub(crate) struct CaptureTarget {
texture: GlesTexture,
damage: OutputDamageTracker,
output: Output,
revision: u64,
id: smithay::backend::renderer::element::Id,
commit: smithay::backend::renderer::utils::CommitCounter,
stamp: Option<changes::Stamp>,
preparations: u64,
reuses: u64,
compositions: u64,
trace: bool,
reported: Instant,
}
impl CaptureTarget {
pub(crate) fn new(texture: GlesTexture, output: &Output) -> Self {
Self {
texture,
damage: OutputDamageTracker::from_output(output),
output: output.clone(),
revision: 0,
id: smithay::backend::renderer::element::Id::new(),
commit: Default::default(),
stamp: None,
preparations: 0,
reuses: 0,
compositions: 0,
trace: std::env::var_os("FERESE_TRACE_PERFORMANCE").is_some(),
reported: Instant::now(),
}
}
}
pub(crate) fn touch_cache(state: &mut Ferese) { pub(crate) fn touch_cache(state: &mut Ferese) {
if state.capture_last_used.replace(Instant::now()).is_some() { if state.capture_last_used.replace(Instant::now()).is_some() {
return; return;
@@ -28,16 +65,28 @@ pub(crate) fn touch_cache(state: &mut Ferese) {
if idle < CACHE_IDLE { if idle < CACHE_IDLE {
return TimeoutAction::ToDuration(CACHE_IDLE - idle); return TimeoutAction::ToDuration(CACHE_IDLE - idle);
} }
if let Some(backend) = state.nested_backend.clone()
&& let Ok(mut backend) = backend.try_borrow_mut()
{
crate::render::clear_stream_capture_targets(backend.renderer());
}
if let Some(backend) = state.direct_backend.as_mut() {
backend.clear_stream_capture_targets();
}
if !state.pending_screencopies.is_empty() || state.image_capture.has_sessions() {
// A damage-waiting stream must retain the comparison image.
return TimeoutAction::ToDuration(CACHE_IDLE);
}
// A stream can pause or fail without another repaint. Release its // A stream can pause or fail without another repaint. Release its
// offscreen targets and filtered-scene caches from the event loop. // offscreen targets and filtered-scene caches from the event loop.
state.capture_last_used = None; state.capture_last_used = None;
state.capture_render = Default::default(); state.capture_render = Default::default();
state.nested_capture_texture = None; state.nested_capture_texture = Default::default();
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
for device in backend.devices.values_mut() { for device in backend.devices.values_mut() {
for output in device.outputs.values_mut() { for output in device.outputs.values_mut() {
output.capture_texture = None; output.capture_texture = Default::default();
output.mirror_capture_texture = None; output.mirror_capture_texture = Default::default();
} }
} }
} }
@@ -52,7 +101,7 @@ pub(crate) fn touch_cache(state: &mut Ferese) {
pub(crate) fn capture_output( pub(crate) fn capture_output(
state: &mut Ferese, state: &mut Ferese,
renderer: &mut GlesRenderer, renderer: &mut GlesRenderer,
texture: &mut Option<GlesTexture>, textures: &mut [Option<CaptureTarget>; 2],
output: &Output, output: &Output,
scene: &FrameScene, scene: &FrameScene,
) { ) {
@@ -61,15 +110,35 @@ pub(crate) fn capture_output(
continue; continue;
} }
// Sample the same scene into an independent target: the displayed let texture = &mut textures[usize::from(include_cursor)];
// primary buffer may omit both a scanned-out client and hardware cursor. if !prepare_capture(
let elements = capture_output_elements(state, renderer, output, include_cursor, scene); state,
let Some(target) = capture_request(state, output, include_cursor, || { renderer,
render_capture(renderer, texture, output, &elements) texture,
output,
output,
include_cursor,
scene,
) {
continue;
}
let texture = texture.as_mut().expect("capture target allocated");
let Some((target, revision)) = capture_request(state, output, include_cursor, || {
renderer
.bind(&mut texture.texture)
.map(|target| (target, texture.revision))
}) else { }) else {
continue; continue;
}; };
state.process_screencopies(renderer, &target, output, include_cursor); state.process_screencopies_with_revision(
renderer,
&target,
output,
include_cursor,
Some(revision),
);
drop(target);
state.process_output_streams(renderer, &texture.texture, output, include_cursor, revision);
} }
} }
@@ -84,47 +153,121 @@ pub(super) fn capture_mirror(
if !state.has_pending_screencopy(&output.output, include_cursor) { if !state.has_pending_screencopy(&output.output, include_cursor) {
continue; continue;
} }
let elements = capture_output_elements(state, renderer, source, include_cursor, scene);
// Never sample mirror_texture: it contains the unfiltered display. // Never sample mirror_texture: it contains the unfiltered display.
let canvas = output let canvas = output
.mirror_canvas .mirror_canvas
.as_ref() .as_ref()
.expect("mirror display composed"); .expect("mirror display composed");
let Some(()) = capture_request(state, &output.output, include_cursor, || { super::mirror::configure_canvas(canvas, source);
super::mirror::compose( let source_target = &mut output.mirror_capture_texture[usize::from(include_cursor)];
if !prepare_capture(
state,
renderer, renderer,
&mut output.mirror_capture_texture, source_target,
canvas,
source, source,
&elements, canvas,
) include_cursor,
}) else { scene,
) {
continue; continue;
}; }
let element = super::mirror::fitted_texture( let source_target = source_target
output
.mirror_capture_texture
.as_ref() .as_ref()
.expect("successful mirror composition populated its capture texture") .expect("mirror capture target allocated");
.clone(), let element = super::mirror::fitted_texture(
source_target.texture.clone(),
&output.output, &output.output,
smithay::backend::renderer::element::Id::new(), source_target.id.clone(),
Default::default(), source_target.commit,
); );
let Some(target) = capture_request(state, &output.output, include_cursor, || { let Some((target, revision)) =
render_capture( capture_request(state, &output.output, include_cursor, || {
render_capture_with_revision(
renderer, renderer,
&mut output.capture_texture, &mut output.capture_texture[usize::from(include_cursor)],
&output.output, &output.output,
&[element], &[element],
) )
}) else { })
else {
continue; continue;
}; };
state.process_screencopies(renderer, &target, &output.output, include_cursor); state.process_screencopies_with_revision(
renderer,
&target,
&output.output,
include_cursor,
Some(revision),
);
drop(target);
state.process_output_streams(
renderer,
&output.capture_texture[usize::from(include_cursor)]
.as_ref()
.expect("capture target allocated")
.texture
.clone(),
&output.output,
include_cursor,
revision,
);
} }
} }
/// Display damage cannot prove a filtered scene is unchanged: an opaque private
/// window may hide updates that must remain visible to capture underneath it.
/// Retain a separate source revision and only prepare the filtered scene on a
/// real mutation. Delivery buffers still receive a complete image.
fn prepare_capture(
state: &mut Ferese,
renderer: &mut GlesRenderer,
target: &mut Option<CaptureTarget>,
source: &Output,
destination: &Output,
include_cursor: bool,
scene: &FrameScene,
) -> bool {
touch_cache(state);
let stamp = changes::Stamp::new(state, source, include_cursor);
let reusable = target.as_ref().is_some_and(|target| {
target.stamp.as_ref() == Some(&stamp)
&& target.output == *destination
&& destination
.current_mode()
.is_some_and(|mode| target.texture.size() == (mode.size.w, mode.size.h).into())
});
if reusable {
target.as_mut().expect("cached capture target").reuses += 1;
} else {
// Keep the capture image separate from the display swapchain and planes.
let elements = capture_output_elements(state, renderer, source, include_cursor, scene);
let Some(framebuffer) = capture_request(state, destination, include_cursor, || {
render_capture_with_revision(renderer, target, destination, &elements)
}) else {
// A failed render can have changed the retained pixels already.
if let Some(target) = target.as_mut() {
target.stamp = None;
}
return false;
};
drop(framebuffer);
let target = target.as_mut().expect("capture target allocated");
target.stamp = Some(stamp);
target.preparations += 1;
}
let target = target.as_mut().expect("capture target allocated");
if target.trace && target.reported.elapsed() >= Duration::from_secs(5) {
tracing::info!(target: "ferese::render", output = %source.name(), include_cursor,
preparations = target.preparations, scene_reuses = target.reuses,
compositions = target.compositions, "capture scene outcomes");
target.reported = Instant::now();
target.preparations = 0;
target.reuses = 0;
target.compositions = 0;
}
true
}
pub(crate) fn capture_request<T, E: Display>( pub(crate) fn capture_request<T, E: Display>(
state: &mut Ferese, state: &mut Ferese,
output: &Output, output: &Output,
@@ -147,7 +290,69 @@ pub(super) fn after_queue<T, E>(queued: Result<T, E>, capture: impl FnOnce()) ->
Ok(queued) Ok(queued)
} }
#[cfg(test)]
pub(super) fn render_capture<'a, E: RenderElement<GlesRenderer>>( pub(super) fn render_capture<'a, E: RenderElement<GlesRenderer>>(
renderer: &mut GlesRenderer,
target: &'a mut Option<CaptureTarget>,
output: &Output,
elements: &[E],
) -> Result<GlesTarget<'a>, DamageError<GlesError>> {
render_capture_with_revision(renderer, target, output, elements).map(|(target, _)| target)
}
pub(super) fn render_capture_with_revision<'a, E: RenderElement<GlesRenderer>>(
renderer: &mut GlesRenderer,
target: &'a mut Option<CaptureTarget>,
output: &Output,
elements: &[E],
) -> Result<(GlesTarget<'a>, u64), DamageError<GlesError>> {
let size = output.current_mode().expect("output has a mode").size;
if target
.as_ref()
.is_none_or(|target| target.texture.size() != (size.w, size.h).into())
{
*target = Some(CaptureTarget::new(
Offscreen::<GlesTexture>::create_buffer(
renderer,
Fourcc::Abgr8888,
(size.w, size.h).into(),
)
.map_err(DamageError::Rendering)?,
output,
));
}
let target = target.as_mut().expect("capture target allocated");
if target.output != *output {
target.damage = OutputDamageTracker::from_output(output);
target.output = output.clone();
}
// This texture retains the previous capture, independently of display
// scanout and damage. Age one lets unchanged pixels survive; a new target
// has no history and is repainted in full. Rebind after every readback.
let mut framebuffer = renderer
.bind(&mut target.texture)
.map_err(DamageError::Rendering)?;
let result = target.damage.render_output(
renderer,
&mut framebuffer,
1,
elements,
[0.035, 0.04, 0.055, 1.0],
)?;
if result
.damage
.as_ref()
.is_some_and(|damage| !damage.is_empty())
{
static NEXT_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
target.commit.increment();
target.compositions += 1;
target.revision = NEXT_REVISION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
Ok((framebuffer, target.revision))
}
pub(super) fn render_texture<'a, E: RenderElement<GlesRenderer>>(
renderer: &mut GlesRenderer, renderer: &mut GlesRenderer,
texture: &'a mut Option<GlesTexture>, texture: &'a mut Option<GlesTexture>,
output: &Output, output: &Output,
@@ -184,6 +389,326 @@ mod tests {
use super::*; use super::*;
#[test]
#[ignore = "requires an EGL rendering device"]
fn unchanged_capture_skips_preparation_without_losing_source_updates() {
if !crate::startup_tests::private_runtime(
"backends::direct::capture::tests::unchanged_capture_skips_preparation_without_losing_source_updates",
) {
return;
}
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let mut config = crate::config::Config::default().runtime_config().unwrap();
config.wallpaper.path = None;
config.animations_enabled = false;
let mut state = Ferese::new(
&mut events,
smithay::reexports::wayland_server::Display::new().unwrap(),
config,
)
.unwrap();
let output = Output::new(
"capture-cache".into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(Mode {
size: (320, 240).into(),
refresh: 60_000,
}),
Some(Transform::Normal),
None,
None,
);
state.space.map_output(&output, (0, 0));
state.register_output(&output, "capture-cache".into());
let (_window, _client) = crate::state::test_window(&mut state, &mut events, 0xffff0000);
state.advance_animations(Instant::now());
let device = EGLDevice::enumerate().unwrap().last().expect("EGL device");
// SAFETY: the device and display outlive this exclusively owned context.
let display = unsafe { EGLDisplay::new(device).unwrap() };
let context = EGLContext::new(&display).unwrap();
// SAFETY: this test thread exclusively owns the fresh context.
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
let mut target = None;
let prepare = |state: &mut Ferese,
renderer: &mut GlesRenderer,
target: &mut Option<CaptureTarget>| {
let scene = state.sample_frame(&output, Duration::ZERO);
assert!(prepare_capture(
state, renderer, target, &output, &output, false, &scene
));
};
let pixels = |renderer: &mut GlesRenderer, target: &mut Option<CaptureTarget>| {
let target = target.as_mut().unwrap();
let framebuffer = renderer.bind(&mut target.texture).unwrap();
let mapping = renderer
.copy_framebuffer(
&framebuffer,
Rectangle::from_size((320, 240).into()),
Fourcc::Abgr8888,
)
.unwrap();
drop(framebuffer);
renderer.map_texture(&mapping).unwrap().to_vec()
};
prepare(&mut state, &mut renderer, &mut target);
// Keep assertions independent of the opt-in periodic reporting clock.
target.as_mut().unwrap().trace = false;
let original = pixels(&mut renderer, &mut target);
for _ in 0..100 {
super::super::schedule_capture(&mut state, std::slice::from_ref(&output));
prepare(&mut state, &mut renderer, &mut target);
}
assert_eq!(target.as_ref().unwrap().preparations, 1);
assert_eq!(target.as_ref().unwrap().reuses, 100);
assert_eq!(pixels(&mut renderer, &mut target), original);
super::super::render_cursor(&mut state);
prepare(&mut state, &mut renderer, &mut target);
assert_eq!(target.as_ref().unwrap().preparations, 1);
state.capture_epoch += 1;
prepare(&mut state, &mut renderer, &mut target);
assert_eq!(target.as_ref().unwrap().preparations, 2);
let (_cover, _cover_client) =
crate::state::test_window(&mut state, &mut events, 0xff00ff00);
state.advance_animations(Instant::now());
prepare(&mut state, &mut renderer, &mut target);
assert_eq!(target.as_ref().unwrap().preparations, 3);
assert_ne!(pixels(&mut renderer, &mut target), original);
}
#[test]
#[ignore = "requires an EGL rendering device"]
fn incremental_capture_matches_full_redraw_after_scene_and_output_changes() {
let device = EGLDevice::enumerate().unwrap().last().expect("EGL device");
// SAFETY: the enumerated device remains alive through its EGL display.
let display = unsafe { EGLDisplay::new(device).unwrap() };
let context = EGLContext::new(&display).unwrap();
// SAFETY: this thread exclusively owns the renderer and its fresh context.
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
let output = |name: &str| {
Output::new(
name.into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
)
};
let outputs = [output("first"), output("second")];
let mut capture = None;
let mut reference = None;
let background = SolidColorRenderElement::new(
Id::new(),
Rectangle::from_size((96, 96).into()),
CommitCounter::default(),
Color32F::new(0., 0., 1., 1.),
Kind::Unspecified,
);
let item_id = Id::new();
for output in outputs.iter().chain(std::iter::once(&outputs[0])) {
for (width, height) in [(64, 48), (48, 64)] {
for scale in [1., 1.25, 1.5] {
for transform in [
Transform::Normal,
Transform::_90,
Transform::_180,
Transform::Flipped,
] {
output.change_current_state(
Some(Mode {
size: (width, height).into(),
refresh: 60_000,
}),
Some(transform),
Some(smithay::output::Scale::Fractional(scale)),
None,
);
let mut commit = CommitCounter::default();
// Include moves, color/alpha changes, removal, cursor
// inclusion/exclusion and an entirely empty scene.
for step in [6, 0, 1, 2, 3, 4, 5, 6] {
if step == 2 {
commit.increment();
}
let mut elements = Vec::new();
if matches!(step, 0..=2 | 4) {
elements.push(SolidColorRenderElement::new(
item_id.clone(),
Rectangle::new(
(if step == 0 { 4 } else { 20 }, 4).into(),
(12, 12).into(),
),
commit,
if step < 2 {
Color32F::new(0., 1., 0., 1.)
} else {
Color32F::new(0.5, 0., 0., 0.5)
},
Kind::Cursor,
));
}
if step != 6 {
elements.push(background.clone());
}
drop(
render_capture(&mut renderer, &mut capture, output, &elements)
.unwrap(),
);
// The repeated scene must preserve the same pixels when
// damage tracking skips all drawing on the next capture.
let actual =
render_capture(&mut renderer, &mut capture, output, &elements)
.unwrap();
let mapping = renderer
.copy_framebuffer(
&actual,
Rectangle::from_size((width, height).into()),
Fourcc::Abgr8888,
)
.unwrap();
let actual_pixels = renderer.map_texture(&mapping).unwrap().to_vec();
drop(actual);
let expected =
render_texture(&mut renderer, &mut reference, output, &elements)
.unwrap();
let mapping = renderer
.copy_framebuffer(
&expected,
Rectangle::from_size((width, height).into()),
Fourcc::Abgr8888,
)
.unwrap();
let expected_pixels = renderer.map_texture(&mapping).unwrap();
assert_eq!(
actual_pixels,
expected_pixels,
"{} {width}x{height} scale={scale} {transform:?} step={step}",
output.name()
);
}
}
}
}
}
}
#[test]
#[ignore = "manual 4K capture benchmark; requires a hardware EGL device"]
fn profile_output_capture() {
let device = EGLDevice::enumerate()
.unwrap()
.find(|device| device.render_device_path().is_ok())
.expect("a hardware EGL rendering device");
eprintln!("capture_benchmark device={:?}", device.render_device_path());
// SAFETY: the enumerated device remains alive through its EGL display.
let display = unsafe { EGLDisplay::new(device).unwrap() };
let context = EGLContext::new(&display).unwrap();
// SAFETY: this thread exclusively owns the renderer and its fresh context.
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
let output = Output::new(
"capture-benchmark".into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(Mode {
size: (3840, 2160).into(),
refresh: 60_000,
}),
Some(Transform::Normal),
None,
None,
);
let mut texture = None;
let background_id = Id::new();
let cursor_id = Id::new();
let mut commit = CommitCounter::default();
let panes: Vec<_> = (0..20)
.map(|i| {
SolidColorRenderElement::new(
Id::new(),
Rectangle::new((i * 150, i * 75).into(), (800, 600).into()),
CommitCounter::default(),
Color32F::new(0.1, 0.2, 0.3, 0.5),
Kind::Unspecified,
)
})
.collect();
for readback in [false, true] {
let size = if readback { (3840, 2160) } else { (1, 1) };
let mut pixels = vec![0u8; size.0 as usize * size.1 as usize * 4];
for workload in ["static", "cursor", "full"] {
for run in 0..=3 {
let started = Instant::now();
commit.increment();
for tick in 0..60 {
let color = if workload == "full" {
commit.increment();
tick as f32 / 60.
} else {
0.1
};
let mut elements = vec![SolidColorRenderElement::new(
cursor_id.clone(),
Rectangle::new(
(if workload == "cursor" { tick * 30 } else { 0 }, 0).into(),
(24, 24).into(),
),
CommitCounter::default(),
Color32F::new(1., 1., 1., 1.),
Kind::Cursor,
)];
elements.extend_from_slice(&panes);
elements.push(SolidColorRenderElement::new(
background_id.clone(),
Rectangle::from_size((3840, 2160).into()),
commit,
Color32F::new(color, 0.1, 0.2, 1.),
Kind::Unspecified,
));
let target =
render_capture(&mut renderer, &mut texture, &output, &elements)
.unwrap();
// Mapping waits for GPU completion in both workloads. The full
// readback also includes the copy into the client's SHM buffer.
let mapping = renderer
.copy_framebuffer(
&target,
Rectangle::from_size(size.into()),
Fourcc::Argb8888,
)
.unwrap();
let source = renderer.map_texture(&mapping).unwrap();
let bytes = pixels.len();
pixels.copy_from_slice(&source[..bytes]);
std::hint::black_box(&pixels);
}
if run != 0 {
eprintln!(
"output_capture_benchmark readback={readback} workload={workload} run={run} frames=60 elapsed_us={}",
started.elapsed().as_micros()
);
}
}
}
}
}
#[test] #[test]
#[ignore = "requires an EGL rendering device"] #[ignore = "requires an EGL rendering device"]
fn capture_recomposes_cursor_without_touching_the_display_target() { fn capture_recomposes_cursor_without_touching_the_display_target() {
@@ -234,7 +759,7 @@ mod tests {
let mut displayed = None; let mut displayed = None;
let mut capture = None; let mut capture = None;
{ {
let _ = render_capture( let _ = render_texture(
&mut renderer, &mut renderer,
&mut displayed, &mut displayed,
&output, &output,
@@ -289,7 +814,7 @@ mod tests {
let _ = let _ =
render_capture::<SolidColorRenderElement>(&mut renderer, &mut capture, &output, &[]) render_capture::<SolidColorRenderElement>(&mut renderer, &mut capture, &output, &[])
.unwrap(); .unwrap();
assert_eq!(capture.as_ref().unwrap().size(), (48, 16).into()); assert_eq!(capture.as_ref().unwrap().texture.size(), (48, 16).into());
} }
} }
@@ -0,0 +1,185 @@
//! Capture invalidation is independent of display damage and consumer requests.
use std::sync::atomic::{AtomicU64, Ordering};
use smithay::output::Output;
use smithay::utils::{Logical, Physical, Rectangle, Size, Transform};
#[derive(Default)]
struct Changes {
content: AtomicU64,
cursor: AtomicU64,
}
pub(crate) fn content_changed(output: &Output) {
if let Some(changes) = output.user_data().get::<Changes>() {
changes.content.fetch_add(1, Ordering::Relaxed);
}
}
pub(crate) fn cursor_changed(output: &Output) {
if let Some(changes) = output.user_data().get::<Changes>() {
changes.cursor.fetch_add(1, Ordering::Relaxed);
}
}
pub(crate) fn tracked(output: &Output) -> bool {
output.user_data().get::<Changes>().is_some()
}
/// Also compare output configuration: mode changes can precede a redraw request.
/// Keeping the source identity prevents a remapped mirror from reusing its old view.
#[derive(Debug, PartialEq)]
pub(super) struct Stamp {
output: Output,
content: u64,
cursor: Option<u64>,
epoch: u64,
cursor_protected: bool,
size: Option<Size<i32, Physical>>,
scale: f64,
transform: Transform,
geometry: Option<Rectangle<i32, Logical>>,
}
impl Stamp {
pub fn new(state: &crate::Ferese, output: &Output, include_cursor: bool) -> Self {
let changes = output
.user_data()
.get_or_insert_threadsafe(Changes::default);
Self {
output: output.clone(),
content: changes.content.load(Ordering::Relaxed),
cursor: include_cursor.then(|| changes.cursor.load(Ordering::Relaxed)),
epoch: state.capture_epoch,
cursor_protected: include_cursor && state.capture_cursor_protected(),
size: output.current_mode().map(|mode| mode.size),
scale: output.current_scale().fractional_scale(),
transform: output.current_transform(),
geometry: state.space.output_geometry(output),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use smithay::output::{Mode, PhysicalProperties, Scale, Subpixel};
fn output() -> Output {
let output = Output::new(
"capture-test".into(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: String::new(),
model: String::new(),
serial_number: String::new(),
},
);
output.change_current_state(
Some(Mode {
size: (640, 480).into(),
refresh: 60_000,
}),
Some(Transform::Normal),
Some(Scale::Fractional(1.25)),
Some((0, 0).into()),
);
output
}
#[test]
fn consumer_demand_does_not_invalidate_but_scene_redraws_do() {
if !crate::startup_tests::private_runtime(
"backends::direct::capture::changes::tests::consumer_demand_does_not_invalidate_but_scene_redraws_do",
) {
return;
}
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let first = output();
let second = output();
state.space.map_output(&first, (0, 0));
state.space.map_output(&second, (640, 0));
let before = Stamp::new(&state, &first, false);
let other = Stamp::new(&state, &second, false);
crate::backends::direct::schedule_capture(&mut state, std::slice::from_ref(&first));
assert_eq!(before, Stamp::new(&state, &first, false));
crate::backends::direct::render_on(&mut state, std::slice::from_ref(&first));
assert_ne!(before, Stamp::new(&state, &first, false));
assert_eq!(other, Stamp::new(&state, &second, false));
let before = Stamp::new(&state, &first, false);
state.defer_output_redraw(first.clone());
assert_ne!(before, Stamp::new(&state, &first, false));
crate::backends::direct::render_all(&mut state);
assert_ne!(other, Stamp::new(&state, &second, false));
}
#[test]
fn cursor_privacy_and_output_configuration_change_the_appropriate_view() {
if !crate::startup_tests::private_runtime(
"backends::direct::capture::changes::tests::cursor_privacy_and_output_configuration_change_the_appropriate_view",
) {
return;
}
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let output = output();
state.space.map_output(&output, (0, 0));
let hidden = Stamp::new(&state, &output, false);
let embedded = Stamp::new(&state, &output, true);
crate::backends::direct::render_cursor(&mut state);
assert_eq!(hidden, Stamp::new(&state, &output, false));
assert_ne!(embedded, Stamp::new(&state, &output, true));
state.capture_epoch += 1;
assert_ne!(hidden, Stamp::new(&state, &output, false));
let before = Stamp::new(&state, &output, false);
output.change_current_state(None, Some(Transform::_180), None, None);
assert_ne!(before, Stamp::new(&state, &output, false));
let before = Stamp::new(&state, &output, false);
output.change_current_state(None, None, Some(Scale::Fractional(1.5)), None);
assert_ne!(before, Stamp::new(&state, &output, false));
let before = Stamp::new(&state, &output, false);
state.space.map_output(&output, (100, 0));
assert_ne!(before, Stamp::new(&state, &output, false));
}
#[test]
fn commits_and_the_final_animation_frame_invalidate_capture() {
if !crate::startup_tests::private_runtime(
"backends::direct::capture::changes::tests::commits_and_the_final_animation_frame_invalidate_capture",
) {
return;
}
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let output = output();
state.space.map_output(&output, (0, 0));
state.register_output(&output, "capture-animation".into());
let (_window, mut client) = crate::state::test_window(&mut state, &mut events, 0xffff0000);
let before = Stamp::new(&state, &output, false);
// Even a callback-only commit goes through the mutation path. Damage
// tracking can subsequently decide that the retained pixels still match.
crate::state::test_client_request(&mut client, 2, 6, &[], None);
crate::state::dispatch_test_client(&mut events, &mut state);
assert_ne!(before, Stamp::new(&state, &output, false));
let mut now = std::time::Instant::now();
state.advance_animations(now);
assert!(state.output_has_animations(&output));
let mut settled = false;
for _ in 0..500 {
let before = Stamp::new(&state, &output, false);
now += std::time::Duration::from_millis(16);
state.advance_animations(now);
assert_ne!(before, Stamp::new(&state, &output, false));
if !state.output_has_animations(&output) {
settled = true;
break;
}
}
assert!(
settled,
"the opening/focus animation must reach its final frame"
);
}
}
+7 -3
View File
@@ -79,6 +79,13 @@ pub(super) fn compose(
source: &Output, source: &Output,
elements: &[crate::render::AnimatedWindowRenderElement], elements: &[crate::render::AnimatedWindowRenderElement],
) -> Result<(), smithay::backend::renderer::gles::GlesError> { ) -> Result<(), smithay::backend::renderer::gles::GlesError> {
configure_canvas(canvas, source);
let framebuffer = capture::render_texture(renderer, texture, canvas, elements)?;
drop(framebuffer);
Ok(())
}
pub(super) fn configure_canvas(canvas: &Output, source: &Output) {
let source_mode = source.current_mode().expect("source has a mode"); let source_mode = source.current_mode().expect("source has a mode");
canvas.change_current_state( canvas.change_current_state(
Some(OutputMode { Some(OutputMode {
@@ -89,9 +96,6 @@ pub(super) fn compose(
Some(source.current_scale()), Some(source.current_scale()),
None, None,
); );
let framebuffer = capture::render_capture(renderer, texture, canvas, elements)?;
drop(framebuffer);
Ok(())
} }
pub(super) fn fitted_texture( pub(super) fn fitted_texture(
+1 -1
View File
@@ -324,7 +324,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
output.surface.reset_buffers(); output.surface.reset_buffers();
output.dmabuf_feedback.invalidate(); output.dmabuf_feedback.invalidate();
output.primary_commit = None; output.primary_commit = None;
output.capture_texture = None; output.capture_texture = Default::default();
output.frame_pending = false; output.frame_pending = false;
output.lock_frame_pending = false; output.lock_frame_pending = false;
output.power.suspended(); output.power.suspended();
@@ -557,7 +557,7 @@ pub(super) fn apply_device(
cancel_output_timers(&state.loop_handle, &mut output); cancel_output_timers(&state.loop_handle, &mut output);
output.frame_pending = false; output.frame_pending = false;
output.primary_commit = None; output.primary_commit = None;
output.capture_texture = None; output.capture_texture = Default::default();
output.power.suspended(); output.power.suspended();
let clock = state let clock = state
.direct_backend .direct_backend
+6
View File
@@ -208,6 +208,12 @@ pub(crate) struct Animation {
pub(crate) buffer_scale: i32, pub(crate) buffer_scale: i32,
} }
impl Animation {
pub(crate) fn capture_revision(&self) -> (Option<CursorIcon>, Instant, usize) {
(self.icon, self.epoch, self.frame)
}
}
impl Default for Animation { impl Default for Animation {
fn default() -> Self { fn default() -> Self {
Self { Self {
+6
View File
@@ -53,6 +53,12 @@ macro_rules! delegate_protocols {
} }
delegate_protocols!( delegate_protocols!(
protocols::ext::image_capture_source::v1::server::ext_image_capture_source_v1::ExtImageCaptureSourceV1,
protocols::ext::image_capture_source::v1::server::ext_output_image_capture_source_manager_v1::ExtOutputImageCaptureSourceManagerV1,
protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_manager_v1::ExtImageCopyCaptureManagerV1,
protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_session_v1::ExtImageCopyCaptureSessionV1,
protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_cursor_session_v1::ExtImageCopyCaptureCursorSessionV1,
protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_frame_v1::ExtImageCopyCaptureFrameV1,
protocols::wp::idle_inhibit::zv1::server::zwp_idle_inhibit_manager_v1::ZwpIdleInhibitManagerV1, protocols::wp::idle_inhibit::zv1::server::zwp_idle_inhibit_manager_v1::ZwpIdleInhibitManagerV1,
protocols::wp::idle_inhibit::zv1::server::zwp_idle_inhibitor_v1::ZwpIdleInhibitorV1, protocols::wp::idle_inhibit::zv1::server::zwp_idle_inhibitor_v1::ZwpIdleInhibitorV1,
protocols::ext::data_control::v1::server::ext_data_control_device_v1::ExtDataControlDeviceV1, protocols::ext::data_control::v1::server::ext_data_control_device_v1::ExtDataControlDeviceV1,
+742
View File
@@ -0,0 +1,742 @@
//! Persistent, damage-driven capture for the native portal. One frame may be
//! outstanding per session; client buffers remain owned until GPU completion.
use smithay::reexports::calloop::RegistrationToken;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use ferese_layout::WindowId;
use smithay::backend::allocator::{Fourcc, Modifier};
use smithay::backend::egl::EGLDevice;
use smithay::backend::renderer::gles::{Capability, GlesRenderer, GlesTexture};
use smithay::backend::renderer::Texture;
use smithay::backend::renderer::utils::{CommitCounter, RendererSurfaceStateUserData, SurfaceView};
use smithay::output::{Output, WeakOutput};
use smithay::reexports::wayland_protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_frame_v1::FailureReason;
use smithay::reexports::wayland_server::backend::ObjectId;
use smithay::reexports::wayland_server::{Client, DisplayHandle, Resource};
use smithay::reexports::wayland_server::protocol::wl_shm;
use smithay::utils::{Buffer, Logical, Point, Rectangle, Size, Transform};
use smithay::wayland::compositor::{TraversalAction, with_surface_tree_downward};
use smithay::wayland::image_capture_source::{ImageCaptureSource, ImageCaptureSourceHandler, OutputCaptureSourceHandler, OutputCaptureSourceState};
use smithay::wayland::image_copy_capture::{BufferConstraints, DmabufConstraints, Frame, FrameRef, ImageCopyCaptureHandler, ImageCopyCaptureState, Session, SessionRef};
use crate::Ferese;
const MAX_SESSIONS: usize = 16;
pub(super) enum Source {
Output(WeakOutput),
Window(WindowId),
}
#[derive(PartialEq)]
struct SurfaceStamp {
id: ObjectId,
commit: CommitCounter,
view: SurfaceView,
scale: i32,
transform: Transform,
alpha: Option<f32>,
}
#[derive(PartialEq)]
struct WindowStamp {
surfaces: Vec<SurfaceStamp>,
geometry: Rectangle<i32, Logical>,
scale: f64,
epoch: u64,
cursor: Option<CursorStamp>,
}
#[derive(PartialEq)]
struct CursorStamp {
location: Point<f64, Logical>,
geometry: Rectangle<i32, Logical>,
image: CursorImageStamp,
}
#[derive(PartialEq)]
enum CursorImageStamp {
Named(Option<smithay::input::pointer::CursorIcon>, Instant, usize),
Surface(Vec<SurfaceStamp>, Point<i32, Logical>),
Hidden,
}
fn surface_stamp(
root: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
crop: Option<Rectangle<i32, Logical>>,
) -> Vec<SurfaceStamp> {
let mut stamp = Vec::new();
with_surface_tree_downward(
root,
Point::<f64, Logical>::default(),
|surface, states, location| {
let Some(data) = states.data_map.get::<RendererSurfaceStateUserData>() else {
return TraversalAction::SkipChildren;
};
use smithay::wayland::alpha_modifier::AlphaModifierSurfaceCachedState;
let alpha = states
.cached_state
.get::<AlphaModifierSurfaceCachedState>()
.current()
.multiplier_f32();
let data = data.lock().expect("surface metadata mutex poisoned");
let Some(view) = data.view() else {
return TraversalAction::SkipChildren;
};
let location = *location + view.offset.to_f64();
if crop.is_none_or(|crop| {
Rectangle::new(location, view.dst.to_f64()).overlaps(crop.to_f64())
}) {
stamp.push(SurfaceStamp {
id: surface.id(),
commit: data.current_commit(),
view,
scale: data.buffer_scale(),
transform: data.buffer_transform(),
alpha,
});
}
// An off-crop parent can have a child offset back into the captured area.
TraversalAction::DoChildren(location)
},
|_, _, _| {},
|_, _, _| true,
);
stamp
}
fn pending_frame_callbacks(
root: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
) -> bool {
let mut pending = false;
with_surface_tree_downward(
root,
(),
|_, states, _| {
if states
.data_map
.get::<RendererSurfaceStateUserData>()
.is_some_and(|data| {
data.lock()
.expect("surface metadata mutex poisoned")
.view()
.is_none()
})
{
return TraversalAction::SkipChildren;
}
pending |= !states
.cached_state
.get::<smithay::wayland::compositor::SurfaceAttributes>()
.current()
.frame_callbacks
.is_empty();
TraversalAction::DoChildren(())
},
|_, _, _| {},
|_, _, _| true,
);
pending
}
struct Active {
session: Session,
pending: Option<Frame>,
revision: Option<u64>,
window_stamp: Option<WindowStamp>,
size: Size<i32, Buffer>,
dma_node: Option<smithay::backend::drm::DrmNode>,
}
struct InFlight {
frame: Frame,
session: SessionRef,
epoch: u64,
fence: RegistrationToken,
timeout: RegistrationToken,
}
pub(crate) struct CaptureSessions {
copy: ImageCopyCaptureState,
outputs: OutputCaptureSourceState,
active: Vec<Active>,
in_flight: HashMap<u64, InFlight>,
next_fence: u64,
callback_timer: Option<RegistrationToken>,
}
fn authorized(client: &Client) -> bool {
super::screencopy::capture_allowed()
&& client
.get_data::<crate::state::ClientState>()
.is_some_and(|state| {
state
.capabilities
.contains(crate::private_client::ClientCapabilities::WINDOW_CAPTURE)
})
}
impl CaptureSessions {
pub(crate) fn new(display: &DisplayHandle) -> Self {
Self {
copy: ImageCopyCaptureState::new_with_filter::<Ferese, _>(display, authorized),
outputs: OutputCaptureSourceState::new_with_filter::<Ferese, _>(display, authorized),
active: Vec::new(),
in_flight: HashMap::new(),
next_fence: 0,
callback_timer: None,
}
}
pub(crate) fn has_sessions(&self) -> bool {
self.active.iter().any(|active| {
active.pending.is_some()
&& matches!(
active.session.source().user_data().get::<Source>(),
Some(Source::Output(_))
)
})
}
pub(super) fn pending_output(&self, output: &Output, cursor: bool) -> bool {
self.active.iter().any(|active| {
active.pending.is_some()
&& active.session.draw_cursor() == cursor
&& matches!(active.session.source().user_data().get::<Source>(),
Some(Source::Output(weak)) if weak.upgrade().as_ref() == Some(output))
})
}
}
pub(super) fn dma_constraints(renderer: &GlesRenderer) -> Option<Arc<DmabufConstraints>> {
struct Cached(Option<Arc<DmabufConstraints>>);
renderer
.egl_context()
.user_data()
.get_or_insert_threadsafe(|| Cached(query_dma_constraints(renderer).map(Arc::new)))
.0
.clone()
}
fn query_dma_constraints(renderer: &GlesRenderer) -> Option<DmabufConstraints> {
if !renderer.capabilities().contains(&Capability::ExportFence) {
return None;
}
let display = renderer.egl_context().display();
if !display
.extensions()
.iter()
.any(|extension| extension == "EGL_ANDROID_native_fence_sync")
{
return None;
}
let node = EGLDevice::device_for_display(display)
.ok()?
.try_get_render_node()
.ok()??;
let format = smithay::backend::allocator::Format {
code: Fourcc::Xrgb8888,
modifier: Modifier::Linear,
};
display
.dmabuf_render_formats()
.contains(&format)
.then_some(DmabufConstraints {
node,
formats: vec![(Fourcc::Xrgb8888, vec![Modifier::Linear])],
})
}
impl ImageCaptureSourceHandler for Ferese {}
impl OutputCaptureSourceHandler for Ferese {
fn output_capture_source_state(&mut self) -> &mut OutputCaptureSourceState {
&mut self.image_capture.outputs
}
fn output_source_created(&mut self, source: ImageCaptureSource, output: &Output) {
source
.user_data()
.insert_if_missing(|| Source::Output(output.downgrade()));
}
}
impl ImageCopyCaptureHandler for Ferese {
fn image_copy_capture_state(&mut self) -> &mut ImageCopyCaptureState {
&mut self.image_capture.copy
}
fn capture_constraints(&mut self, source: &ImageCaptureSource) -> Option<BufferConstraints> {
let (size, output) = self.capture_source_geometry(source)?;
Some(BufferConstraints {
size,
shm: vec![wl_shm::Format::Xrgb8888],
dma: self.capture_dma_constraints(&output).as_deref().cloned(),
})
}
fn new_session(&mut self, session: Session) {
if self.image_capture.active.len() >= MAX_SESSIONS {
return;
}
let Some(constraints) = session.current_constraints() else {
return;
};
self.image_capture.active.push(Active {
session,
pending: None,
revision: None,
window_stamp: None,
size: constraints.size,
dma_node: constraints.dma.as_ref().map(|dma| dma.node),
});
}
fn frame(&mut self, session: &SessionRef, frame: Frame) {
let Some(active) = self
.image_capture
.active
.iter_mut()
.find(|active| active.session.as_ref() == *session)
else {
return;
};
if active.pending.is_some() {
frame.fail(FailureReason::Unknown);
return;
}
active.pending = Some(frame);
if let Some(Source::Output(weak)) = session.source().user_data().get::<Source>()
&& let Some(output) = weak.upgrade()
{
crate::backends::direct::schedule_capture(self, &[output]);
}
}
fn session_destroyed(&mut self, session: SessionRef) {
self.image_capture
.active
.retain(|active| active.session.as_ref() != session);
let fences: Vec<_> = self
.image_capture
.in_flight
.iter()
.filter_map(|(id, pending)| (pending.session == session).then_some(*id))
.collect();
for id in fences {
self.cancel_capture_fence(id);
}
}
fn frame_aborted(&mut self, frame: FrameRef) {
let fences: Vec<_> = self
.image_capture
.in_flight
.iter()
.filter_map(|(id, pending)| (pending.frame == frame).then_some(*id))
.collect();
for id in fences {
self.cancel_capture_fence(id);
}
for active in &mut self.image_capture.active {
if active
.pending
.as_ref()
.is_some_and(|pending| *pending == frame)
{
active.pending.take();
}
}
}
}
impl Ferese {
fn capture_source_geometry(
&self,
source: &ImageCaptureSource,
) -> Option<(Size<i32, Buffer>, Output)> {
if self.session_lock.active() || !source.alive() || !super::screencopy::capture_allowed() {
return None;
}
let (size, output) = match source.user_data().get::<Source>()? {
Source::Window(id) => {
let info = self.window_capture_info(*id)?;
let size = info.geometry.size.to_physical_precise_round(info.scale);
(Size::from((size.w, size.h)), info.output)
}
Source::Output(weak) => {
let output = weak.upgrade()?;
if !self.output_ids.contains_key(&output) {
return None;
}
let size = output
.current_transform()
.transform_size(output.current_mode()?.size);
(Size::from((size.w, size.h)), output)
}
};
let bytes = u64::try_from(size.w)
.ok()?
.checked_mul(u64::try_from(size.h).ok()?)?
.checked_mul(4)?;
if bytes == 0 || bytes > 128 * 1024 * 1024 {
return None;
}
Some((size, output))
}
fn capture_dma_constraints(&mut self, output: &Output) -> Option<Arc<DmabufConstraints>> {
if let Some(backend) = self.nested_backend.clone() {
backend
.try_borrow_mut()
.ok()
.and_then(|mut backend| dma_constraints(backend.renderer()))
} else {
self.direct_backend
.as_mut()
.and_then(|backend| backend.capture_renderer(output))
.and_then(|renderer| dma_constraints(renderer))
}
}
pub(super) fn refresh_capture_source(&mut self, source: &ImageCaptureSource) {
let mut outputs = Vec::new();
for active in &mut self.image_capture.active {
if active.session.source() != *source {
continue;
}
active.revision = None;
active.window_stamp = None;
if active.pending.is_some()
&& let Some(Source::Output(weak)) = source.user_data().get::<Source>()
&& let Some(output) = weak.upgrade()
&& !outputs.contains(&output)
{
outputs.push(output);
}
}
crate::backends::direct::schedule_capture(self, &outputs);
}
pub(crate) fn refresh_capture_sessions(&mut self) {
self.image_capture.copy.cleanup();
let mut callback_delay: Option<Duration> = None;
let mut sessions = std::mem::take(&mut self.image_capture.active);
sessions.retain_mut(|active| {
let Some((size, output)) = self.capture_source_geometry(&active.session.source())
else {
let session = active.session.as_ref();
let fences: Vec<_> = self
.image_capture
.in_flight
.iter()
.filter_map(|(id, pending)| (pending.session == session).then_some(*id))
.collect();
for id in fences {
self.cancel_capture_fence(id);
}
return false;
};
let dma = self.capture_dma_constraints(&output);
let dma_node = dma.as_ref().map(|dma| dma.node);
if size != active.size || dma_node != active.dma_node {
active.size = size;
active.dma_node = dma_node;
active.revision = None;
active.window_stamp = None;
active.session.update_constraints(BufferConstraints {
size,
shm: vec![wl_shm::Format::Xrgb8888],
dma: dma.as_deref().cloned(),
});
if let Some(frame) = active.pending.take() {
frame.fail(FailureReason::BufferConstraints);
}
}
if let Some(Source::Window(id)) = active.session.source().user_data().get::<Source>() {
let id = *id;
if let Some(frame) = active.pending.take() {
let Some(info) = self.window_capture_info(id) else {
return false;
};
let callback_interval = Duration::from_nanos(
1_000_000_000_000
/ info
.output
.current_mode()
.map(|mode| mode.refresh.max(1000) as u64)
.unwrap_or(60_000),
);
if let Some(toplevel) = info.window.toplevel()
&& pending_frame_callbacks(toplevel.wl_surface())
{
smithay::desktop::utils::send_frames_surface_tree(
toplevel.wl_surface(),
&info.output,
self.start_time.elapsed(),
Some(callback_interval),
|_, _| None,
);
}
let mut stamp = WindowStamp {
surfaces: Vec::new(),
geometry: info.geometry,
scale: info.scale,
epoch: self.capture_epoch,
cursor: None,
};
if let Some(toplevel) = info.window.toplevel() {
stamp.surfaces = surface_stamp(toplevel.wl_surface(), Some(info.geometry));
}
let cursor = active
.session
.draw_cursor()
.then(|| self.window_capture_cursor(id, &info.window, info.geometry))
.flatten();
if let Some(geometry) = cursor
&& let Some(pointer) = self.seat.get_pointer()
{
use smithay::input::pointer::CursorImageStatus;
let image = match &self.cursor_status {
CursorImageStatus::Named(_) => {
let (icon, epoch, frame) = self.cursor_animation.capture_revision();
CursorImageStamp::Named(icon, epoch, frame)
}
CursorImageStatus::Surface(surface) => {
let hotspot =
smithay::wayland::compositor::with_states(surface, |states| {
states
.data_map
.get::<smithay::input::pointer::CursorImageSurfaceData>(
)
.map(|data| {
data.lock()
.expect("cursor metadata mutex poisoned")
.hotspot
})
.unwrap_or_default()
});
CursorImageStamp::Surface(surface_stamp(surface, None), hotspot)
}
CursorImageStatus::Hidden => CursorImageStamp::Hidden,
};
stamp.cursor = Some(CursorStamp {
location: pointer.current_location(),
geometry,
image,
});
}
if active.window_stamp.as_ref() == Some(&stamp) {
active.pending = Some(frame);
if info
.window
.toplevel()
.is_some_and(|toplevel| pending_frame_callbacks(toplevel.wl_surface()))
{
callback_delay =
Some(callback_delay.map_or(callback_interval, |delay| {
delay.min(callback_interval)
}));
}
} else {
crate::backends::direct::capture::touch_cache(self);
let result = if let Some(backend) = self.nested_backend.clone() {
backend
.try_borrow_mut()
.map_err(|error| error.to_string())
.and_then(|mut backend| {
crate::render::capture_stream_window(
backend.renderer(),
self,
&info,
cursor,
&frame.buffer(),
)
})
} else if let Some(mut backend) = self.direct_backend.take() {
let result = backend
.capture_renderer(&info.output)
.ok_or_else(|| "Capture output is unavailable".to_string())
.and_then(|renderer| {
crate::render::capture_stream_window(
renderer,
self,
&info,
cursor,
&frame.buffer(),
)
});
self.direct_backend = Some(backend);
result
} else {
Err("Capture renderer is unavailable".into())
};
active.window_stamp = Some(stamp);
self.finish_stream_frame(frame, active.session.as_ref(), result);
}
}
}
true
});
self.image_capture.active = sessions;
if let Some(delay) = callback_delay {
if self.image_capture.callback_timer.is_none() {
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
match self
.loop_handle
.insert_source(Timer::from_duration(delay), |_, _, state| {
state.image_capture.callback_timer = None;
TimeoutAction::Drop
}) {
Ok(token) => self.image_capture.callback_timer = Some(token),
Err(error) => {
tracing::warn!(%error, "could not schedule captured window callbacks")
}
}
}
} else if let Some(timer) = self.image_capture.callback_timer.take() {
self.loop_handle.remove(timer);
}
}
pub(crate) fn process_output_streams(
&mut self,
renderer: &mut GlesRenderer,
texture: &GlesTexture,
output: &Output,
cursor: bool,
revision: u64,
) {
let mut sessions = std::mem::take(&mut self.image_capture.active);
for active in &mut sessions {
let matches = matches!(active.session.source().user_data().get::<Source>(), Some(Source::Output(weak)) if weak.upgrade().as_ref() == Some(output));
if matches {
let size = output.current_transform().transform_size(texture.size());
if active.size != Size::from((size.w, size.h)) {
if let Some(frame) = active.pending.take() {
frame.fail(FailureReason::BufferConstraints);
}
active.revision = None;
continue;
}
}
if matches
&& active.session.draw_cursor() == cursor
&& active.revision != Some(revision)
&& let Some(frame) = active.pending.take()
{
let result = crate::render::capture_stream_texture(
renderer,
texture,
&frame.buffer(),
output.current_transform(),
false,
);
active.revision = Some(revision);
self.finish_stream_frame(frame, active.session.as_ref(), result);
}
}
self.image_capture.active = sessions;
}
fn cancel_capture_fence(&mut self, id: u64) {
if let Some(pending) = self.image_capture.in_flight.remove(&id) {
self.loop_handle.remove(pending.fence);
self.loop_handle.remove(pending.timeout);
}
}
fn finish_stream_frame(
&mut self,
frame: Frame,
session: SessionRef,
result: Result<smithay::backend::renderer::sync::SyncPoint, String>,
) {
match result {
Ok(sync) if sync.is_reached() => frame.success(
Transform::Normal,
None,
smithay::utils::Clock::<smithay::utils::Monotonic>::new().now(),
),
Ok(sync) => {
let Some(fd) = sync.export() else {
frame.fail(FailureReason::Unknown);
return;
};
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use smithay::reexports::calloop::{Interest, Mode, PostAction, generic::Generic};
self.image_capture.next_fence = self.image_capture.next_fence.wrapping_add(1);
let id = self.image_capture.next_fence;
let source = Generic::new(fd, Interest::READ, Mode::OneShot);
let fence = match self.loop_handle.insert_source(source, move |_, _, state| {
if let Some(pending) = state.image_capture.in_flight.remove(&id) {
state.loop_handle.remove(pending.timeout);
if pending.epoch == state.capture_epoch
&& state
.capture_source_geometry(&pending.session.source())
.is_some()
&& !pending.frame.has_failed()
{
pending.frame.success(
Transform::Normal,
None,
smithay::utils::Clock::<smithay::utils::Monotonic>::new().now(),
);
} else {
pending.frame.fail(
if state
.capture_source_geometry(&pending.session.source())
.is_some()
{
FailureReason::BufferConstraints
} else {
FailureReason::Stopped
},
);
}
}
Ok(PostAction::Remove)
}) {
Ok(token) => token,
Err(error) => {
tracing::warn!(%error, "could not register capture fence");
frame.fail(FailureReason::Unknown);
return;
}
};
let timeout = match self.loop_handle.insert_source(
Timer::from_duration(Duration::from_secs(5)),
move |_, _, state| {
if let Some(pending) = state.image_capture.in_flight.remove(&id) {
state.loop_handle.remove(pending.fence);
pending.frame.fail(FailureReason::Stopped);
state
.image_capture
.active
.retain(|active| active.session.as_ref() != pending.session);
}
TimeoutAction::Drop
},
) {
Ok(token) => token,
Err(error) => {
self.loop_handle.remove(fence);
tracing::warn!(%error, "could not register capture deadline");
frame.fail(FailureReason::Unknown);
return;
}
};
self.image_capture.in_flight.insert(
id,
InFlight {
frame,
session,
epoch: self.capture_epoch,
fence,
timeout,
},
);
}
Err(error) => {
tracing::warn!(%error, "stream capture failed");
frame.fail(FailureReason::Unknown);
}
}
}
}
+1
View File
@@ -2,6 +2,7 @@ pub(crate) mod activation;
mod compositor; mod compositor;
mod dispatch; mod dispatch;
pub(crate) mod gamma_control; pub(crate) mod gamma_control;
pub(crate) mod image_capture;
mod input_method; mod input_method;
mod layer_shell; mod layer_shell;
pub(crate) mod screencopy; pub(crate) mod screencopy;
+81 -6
View File
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -22,6 +23,8 @@ use crate::Ferese;
const BYTES_PER_PIXEL: usize = 4; const BYTES_PER_PIXEL: usize = 4;
type DamageHistory = Arc<Mutex<HashMap<(Output, bool), u64>>>;
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct FrameData { pub(crate) struct FrameData {
pub(super) output: Option<Output>, pub(super) output: Option<Output>,
@@ -29,6 +32,7 @@ pub(crate) struct FrameData {
pub(super) overlay_cursor: bool, pub(super) overlay_cursor: bool,
pub(super) used: Mutex<bool>, pub(super) used: Mutex<bool>,
pub(super) snapshot: Mutex<Option<super::window_capture::Snapshot>>, pub(super) snapshot: Mutex<Option<super::window_capture::Snapshot>>,
pub(super) history: Option<DamageHistory>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -38,6 +42,7 @@ pub(crate) struct PendingScreencopy {
region: Rectangle<i32, Buffer>, region: Rectangle<i32, Buffer>,
overlay_cursor: bool, overlay_cursor: bool,
with_damage: bool, with_damage: bool,
history: Option<DamageHistory>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -140,6 +145,7 @@ impl PendingScreencopy {
// The compositor-owned path deliberately omits the cursor. // The compositor-owned path deliberately omits the cursor.
overlay_cursor: false, overlay_cursor: false,
with_damage: false, with_damage: false,
history: None,
} }
} }
@@ -187,17 +193,17 @@ impl GlobalDispatch<ZwlrScreencopyManagerV1, ()> for Ferese {
_global_data: &(), _global_data: &(),
data_init: &mut DataInit<'_, Self>, data_init: &mut DataInit<'_, Self>,
) { ) {
data_init.init(resource, ()); data_init.init(resource, DamageHistory::default());
} }
} }
impl Dispatch<ZwlrScreencopyManagerV1, ()> for Ferese { impl Dispatch<ZwlrScreencopyManagerV1, DamageHistory> for Ferese {
fn request( fn request(
_state: &mut Self, _state: &mut Self,
_client: &Client, _client: &Client,
_manager: &ZwlrScreencopyManagerV1, _manager: &ZwlrScreencopyManagerV1,
request: zwlr_screencopy_manager_v1::Request, request: zwlr_screencopy_manager_v1::Request,
_data: &(), history: &DamageHistory,
_display: &DisplayHandle, _display: &DisplayHandle,
data_init: &mut DataInit<'_, Self>, data_init: &mut DataInit<'_, Self>,
) { ) {
@@ -207,7 +213,14 @@ impl Dispatch<ZwlrScreencopyManagerV1, ()> for Ferese {
overlay_cursor, overlay_cursor,
output, output,
} => { } => {
create_frame(data_init, frame, output, None, overlay_cursor != 0); create_frame(
data_init,
frame,
output,
None,
overlay_cursor != 0,
history.clone(),
);
} }
zwlr_screencopy_manager_v1::Request::CaptureOutputRegion { zwlr_screencopy_manager_v1::Request::CaptureOutputRegion {
frame, frame,
@@ -224,6 +237,7 @@ impl Dispatch<ZwlrScreencopyManagerV1, ()> for Ferese {
output, output,
Some(Rectangle::new((x, y).into(), (width, height).into())), Some(Rectangle::new((x, y).into(), (width, height).into())),
overlay_cursor != 0, overlay_cursor != 0,
history.clone(),
); );
} }
zwlr_screencopy_manager_v1::Request::Destroy => {} zwlr_screencopy_manager_v1::Request::Destroy => {}
@@ -307,6 +321,12 @@ impl Ferese {
return; return;
}; };
if let Some(history) = &data.history {
history
.lock()
.expect("capture damage history mutex poisoned")
.retain(|(output, _), _| self.output_ids.contains_key(output));
}
self.pending_screencopies.push(PendingScreencopy { self.pending_screencopies.push(PendingScreencopy {
sink: CaptureSink::Shm { sink: CaptureSink::Shm {
frame: frame.clone(), frame: frame.clone(),
@@ -316,8 +336,24 @@ impl Ferese {
region: data.region, region: data.region,
overlay_cursor: data.overlay_cursor, overlay_cursor: data.overlay_cursor,
with_damage, with_damage,
history: data.history.clone(),
});
crate::backends::direct::schedule_capture(self, &[output]);
}
pub(crate) fn prune_screencopies(&mut self) {
self.pending_screencopies.retain(|capture| {
if let CaptureSink::Shm { frame, buffer } = &capture.sink {
if !frame.is_alive() {
return false;
}
if !buffer.is_alive() {
frame.failed();
return false;
}
}
true
}); });
crate::backends::direct::render_on(self, &[output]);
} }
pub(crate) fn process_screencopies<R>( pub(crate) fn process_screencopies<R>(
@@ -330,6 +366,17 @@ impl Ferese {
where where
R: ExportMem, R: ExportMem,
{ {
self.process_screencopies_with_revision(renderer, framebuffer, output, overlay_cursor, None)
}
pub(crate) fn process_screencopies_with_revision<R: ExportMem>(
&mut self,
renderer: &mut R,
framebuffer: &R::Framebuffer<'_>,
output: &Output,
overlay_cursor: bool,
revision: Option<u64>,
) -> bool {
let mut remaining = Vec::new(); let mut remaining = Vec::new();
let mut framebuffer_binding_changed = false; let mut framebuffer_binding_changed = false;
let captures = std::mem::take(&mut self.pending_screencopies); let captures = std::mem::take(&mut self.pending_screencopies);
@@ -347,10 +394,33 @@ impl Ferese {
continue; continue;
} }
let key = (output.clone(), overlay_cursor);
if capture.history.is_some() && revision.is_none() {
remaining.push(capture);
continue;
}
if capture.with_damage {
let previous = capture.history.as_ref().and_then(|history| {
history
.lock()
.expect("capture damage history mutex poisoned")
.get(&key)
.copied()
});
if revision.is_none() || previous == revision {
remaining.push(capture);
continue;
}
}
framebuffer_binding_changed = true; framebuffer_binding_changed = true;
if complete_capture(renderer, framebuffer, &capture, self.start_time.elapsed()).is_err() if complete_capture(renderer, framebuffer, &capture, self.start_time.elapsed()).is_err()
{ {
capture.fail(); capture.fail();
} else if let (Some(history), Some(revision)) = (&capture.history, revision) {
history
.lock()
.expect("capture damage history mutex poisoned")
.insert(key, revision);
} }
} }
@@ -359,7 +429,8 @@ impl Ferese {
} }
pub(crate) fn has_pending_screencopy(&self, output: &Output, overlay_cursor: bool) -> bool { pub(crate) fn has_pending_screencopy(&self, output: &Output, overlay_cursor: bool) -> bool {
self.pending_screencopies.iter().any(|capture| { self.image_capture.pending_output(output, overlay_cursor)
|| self.pending_screencopies.iter().any(|capture| {
capture.output == *output capture.output == *output
&& cursor_overlay_matches(capture.overlay_cursor, overlay_cursor) && cursor_overlay_matches(capture.overlay_cursor, overlay_cursor)
}) })
@@ -391,6 +462,7 @@ fn create_frame(
wl_output: smithay::reexports::wayland_server::protocol::wl_output::WlOutput, wl_output: smithay::reexports::wayland_server::protocol::wl_output::WlOutput,
requested: Option<Rectangle<i32, Logical>>, requested: Option<Rectangle<i32, Logical>>,
overlay_cursor: bool, overlay_cursor: bool,
history: DamageHistory,
) { ) {
let Some(output) = Output::from_resource(&wl_output) else { let Some(output) = Output::from_resource(&wl_output) else {
let frame = data_init.init( let frame = data_init.init(
@@ -401,6 +473,7 @@ fn create_frame(
overlay_cursor, overlay_cursor,
used: Mutex::new(true), used: Mutex::new(true),
snapshot: Mutex::new(None), snapshot: Mutex::new(None),
history: Some(history.clone()),
}), }),
); );
frame.failed(); frame.failed();
@@ -415,6 +488,7 @@ fn create_frame(
overlay_cursor, overlay_cursor,
used: Mutex::new(true), used: Mutex::new(true),
snapshot: Mutex::new(None), snapshot: Mutex::new(None),
history: Some(history.clone()),
}), }),
); );
frame.failed(); frame.failed();
@@ -428,6 +502,7 @@ fn create_frame(
overlay_cursor, overlay_cursor,
used: Mutex::new(false), used: Mutex::new(false),
snapshot: Mutex::new(None), snapshot: Mutex::new(None),
history: Some(history.clone()),
}); });
let frame = data_init.init(frame, data); let frame = data_init.init(frame, data);
+164 -18
View File
@@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex};
use ferese_layout::WindowId; use ferese_layout::WindowId;
use ferese_protocols::window_capture::v1::server::ferese_window_capture_manager_v1 as manager; use ferese_protocols::window_capture::v1::server::ferese_window_capture_manager_v1 as manager;
use ferese_protocols::window_capture::v1::server::ferese_window_capture_manager_v1::FereseWindowCaptureManagerV1; use ferese_protocols::window_capture::v1::server::ferese_window_capture_manager_v1::FereseWindowCaptureManagerV1;
use smithay::backend::renderer::utils::{RendererSurfaceStateUserData, SurfaceView};
use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_frame_v1 as frame; use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_frame_v1 as frame;
use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_frame_v1::ZwlrScreencopyFrameV1; use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_frame_v1::ZwlrScreencopyFrameV1;
use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer; use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer;
@@ -11,7 +12,8 @@ use smithay::reexports::wayland_server::protocol::wl_shm;
use smithay::reexports::wayland_server::{ use smithay::reexports::wayland_server::{
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource,
}; };
use smithay::utils::Rectangle; use smithay::utils::{Logical, Point, Rectangle};
use smithay::wayland::compositor::{TraversalAction, with_surface_tree_downward};
use smithay::wayland::shm::with_buffer_contents_mut; use smithay::wayland::shm::with_buffer_contents_mut;
use super::screencopy::FrameData; use super::screencopy::FrameData;
@@ -21,6 +23,15 @@ use crate::Ferese;
const MAX_BYTES: usize = 128 * 1024 * 1024; const MAX_BYTES: usize = 128 * 1024 * 1024;
const MAX_FRAMES: usize = 8; const MAX_FRAMES: usize = 8;
fn capture_scale(output_scale: f64, buffer_scale: i32, view: SurfaceView) -> f64 {
if view.dst.w <= 0 || view.dst.h <= 0 {
return output_scale;
}
let native_x = view.src.size.w * f64::from(buffer_scale) / f64::from(view.dst.w);
let native_y = view.src.size.h * f64::from(buffer_scale) / f64::from(view.dst.h);
output_scale.max(native_x).max(native_y)
}
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub(crate) struct Budget { pub(crate) struct Budget {
bytes: usize, bytes: usize,
@@ -155,7 +166,7 @@ pub(super) fn init_global(
} }
display.create_global::<Ferese, FereseWindowCaptureManagerV1, Global>( display.create_global::<Ferese, FereseWindowCaptureManagerV1, Global>(
1, 2,
Global { Global {
budget: Arc::default(), budget: Arc::default(),
worker, worker,
@@ -237,6 +248,44 @@ impl Dispatch<FereseWindowCaptureManagerV1, Global> for Ferese {
_: &DisplayHandle, _: &DisplayHandle,
data_init: &mut DataInit<'_, Self>, data_init: &mut DataInit<'_, Self>,
) { ) {
if let manager::Request::RefreshSource { source } = request {
if let Some(source) =
smithay::wayland::image_capture_source::ImageCaptureSource::from_resource(&source)
{
state.refresh_capture_source(&source);
}
return;
}
if let manager::Request::CreateSource {
window_hi,
window_lo,
source,
} = request
{
use smithay::wayland::image_capture_source::{
ImageCaptureSource, ImageCaptureSourceData,
};
let id = WindowId((u64::from(window_hi) << 32) | u64::from(window_lo));
let capture = ImageCaptureSource::new();
capture
.user_data()
.insert_if_missing(|| super::image_capture::Source::Window(id));
let resource = data_init.init(
source,
ImageCaptureSourceData {
source: capture.clone(),
},
);
capture.add_instance(&resource);
if let Some(info) = state.window_capture_info(id) {
manager.source_geometry(
&resource,
info.geometry.size.w as u32,
info.geometry.size.h as u32,
);
}
return;
}
if let manager::Request::CaptureWindow { if let manager::Request::CaptureWindow {
window_hi, window_hi,
window_lo, window_lo,
@@ -262,6 +311,7 @@ impl Dispatch<FereseWindowCaptureManagerV1, Global> for Ferese {
overlay_cursor: false, overlay_cursor: false,
used: Mutex::new(failed), used: Mutex::new(failed),
snapshot: Mutex::new(None), snapshot: Mutex::new(None),
history: None,
}), }),
); );
if failed { if failed {
@@ -293,14 +343,16 @@ impl Dispatch<FereseWindowCaptureManagerV1, Global> for Ferese {
} }
} }
pub(crate) struct WindowInfo {
pub window: smithay::desktop::Window,
pub output: smithay::output::Output,
pub geometry: Rectangle<i32, Logical>,
pub scale: f64,
pub bytes: usize,
}
impl Ferese { impl Ferese {
fn window_snapshot( pub(super) fn window_capture_info(&self, id: WindowId) -> Option<WindowInfo> {
&mut self,
id: WindowId,
overlay_cursor: bool,
budget: &Arc<Mutex<Budget>>,
busy: &mut bool,
) -> Option<Snapshot> {
if self.session_lock.active() || !super::screencopy::capture_allowed() { if self.session_lock.active() || !super::screencopy::capture_allowed() {
return None; return None;
} }
@@ -323,7 +375,34 @@ impl Ferese {
.0 .0
.clone(); .clone();
let geometry = window.geometry(); let geometry = window.geometry();
let scale = output.current_scale().fractional_scale(); let output_scale = output.current_scale().fractional_scale();
// A low-density parent can contain higher-density content. Match the
// rendered surface tree, excluding buffers outside the captured geometry.
let mut scale = output_scale;
if let Some(toplevel) = window.toplevel() {
with_surface_tree_downward(
toplevel.wl_surface(),
Point::<f64, Logical>::default(),
|_, states, location| {
let Some(data) = states.data_map.get::<RendererSurfaceStateUserData>() else {
return TraversalAction::SkipChildren;
};
let data = data
.lock()
.expect("Smithay surface metadata must remain consistent across callbacks");
let Some(view) = data.view() else {
return TraversalAction::SkipChildren;
};
let location = *location + view.offset.to_f64();
if Rectangle::new(location, view.dst.to_f64()).overlaps(geometry.to_f64()) {
scale = capture_scale(scale, data.buffer_scale(), view);
}
TraversalAction::DoChildren(location)
},
|_, _, _| {},
|_, _, _| true,
);
}
let size: smithay::utils::Size<i32, smithay::utils::Physical> = let size: smithay::utils::Size<i32, smithay::utils::Physical> =
geometry.size.to_physical_precise_round(scale); geometry.size.to_physical_precise_round(scale);
let bytes = usize::try_from(size.w) let bytes = usize::try_from(size.w)
@@ -333,16 +412,25 @@ impl Ferese {
if bytes == 0 || bytes > MAX_BYTES { if bytes == 0 || bytes > MAX_BYTES {
return None; return None;
} }
let Some(permit) = Permit::reserve(budget, bytes) else { Some(WindowInfo {
*busy = true; window,
return None; output,
}; geometry,
let cursor = scale,
if overlay_cursor && !self.overview.is_presenting() && !self.capture_cursor_protected() bytes,
{ })
}
pub(super) fn window_capture_cursor(
&self,
id: WindowId,
window: &smithay::desktop::Window,
geometry: Rectangle<i32, Logical>,
) -> Option<Rectangle<i32, Logical>> {
if !self.overview.is_presenting() && !self.capture_cursor_protected() {
self.seat.get_pointer().and_then(|pointer| { self.seat.get_pointer().and_then(|pointer| {
let location = pointer.current_location(); let location = pointer.current_location();
if self.window_under_visual(location).as_ref() != Some(&window) { if self.window_under_visual(location).as_ref() != Some(window) {
return None; return None;
} }
let (x, y) = self.inverse_presented_window_point(id, location.x, location.y)?; let (x, y) = self.inverse_presented_window_point(id, location.x, location.y)?;
@@ -359,7 +447,30 @@ impl Ferese {
}) })
} else { } else {
None None
}
}
fn window_snapshot(
&mut self,
id: WindowId,
overlay_cursor: bool,
budget: &Arc<Mutex<Budget>>,
busy: &mut bool,
) -> Option<Snapshot> {
let WindowInfo {
window,
output,
geometry,
scale,
bytes,
} = self.window_capture_info(id)?;
let Some(permit) = Permit::reserve(budget, bytes) else {
*busy = true;
return None;
}; };
let cursor = overlay_cursor
.then(|| self.window_capture_cursor(id, &window, geometry))
.flatten();
let result = if let Some(backend) = self.nested_backend.clone() { let result = if let Some(backend) = self.nested_backend.clone() {
let mut backend = backend.try_borrow_mut().ok()?; let mut backend = backend.try_borrow_mut().ok()?;
crate::render::capture_window_frame( crate::render::capture_window_frame(
@@ -476,6 +587,40 @@ pub(super) fn copy_snapshot(
pub(crate) mod tests { pub(crate) mod tests {
use super::*; use super::*;
#[test]
fn sharing_preserves_backing_density_at_fractional_display_scales() {
let integer = SurfaceView {
src: Rectangle::from_size((640., 480.).into()),
dst: (640, 480).into(),
offset: (0, 0).into(),
};
let viewport = SurfaceView {
src: Rectangle::from_size((1280., 960.).into()),
..integer
};
for output_scale in [1., 1.25, 1.5, 1.75, 2.] {
assert_eq!(capture_scale(output_scale, 2, integer), 2.);
assert_eq!(capture_scale(output_scale, 1, viewport), 2.);
}
assert_eq!(capture_scale(2.5, 2, integer), 2.5);
let fractional = SurfaceView {
src: Rectangle::from_size((1120., 840.).into()),
..integer
};
assert_eq!(capture_scale(1.25, 1, fractional), 1.75);
assert_eq!(capture_scale(1.75, 1, fractional), 1.75);
}
#[test]
fn sharing_uses_the_viewport_crop_instead_of_the_whole_buffer() {
let cropped = SurfaceView {
src: Rectangle::new((320., 240.).into(), (640., 480.).into()),
dst: (640, 480).into(),
offset: (0, 0).into(),
};
assert_eq!(capture_scale(1.75, 1, cropped), 1.75);
}
/// Exercise both asynchronous publication boundaries using real resources. /// Exercise both asynchronous publication boundaries using real resources.
pub(crate) fn check_deferred_snapshot( pub(crate) fn check_deferred_snapshot(
state: &Ferese, state: &Ferese,
@@ -505,6 +650,7 @@ pub(crate) mod tests {
overlay_cursor: false, overlay_cursor: false,
used: Mutex::new(false), used: Mutex::new(false),
snapshot: Mutex::new(None), snapshot: Mutex::new(None),
history: None,
}) })
}; };
let (worker, _requests) = std::sync::mpsc::sync_channel(1); let (worker, _requests) = std::sync::mpsc::sync_channel(1);
+2
View File
@@ -209,6 +209,8 @@ fn after_dispatch_with_redraw(
) { ) {
crate::backends::direct::gamma::sync_night_light(state); crate::backends::direct::gamma::sync_night_light(state);
state.process_pending_dmabuf_imports(); state.process_pending_dmabuf_imports();
state.prune_screencopies();
state.refresh_capture_sessions();
state.update_named_cursor(std::time::Instant::now()); state.update_named_cursor(std::time::Instant::now());
state.poll_theme(); state.poll_theme();
if state.session_lock.active() || state.input_capture.active() { if state.session_lock.active() || state.input_capture.active() {
+593 -5
View File
@@ -179,14 +179,37 @@ pub(crate) fn capture_window_frame(
/// Convert premultiplied ABGR readback into the capture protocol's straight ARGB. /// Convert premultiplied ABGR readback into the capture protocol's straight ARGB.
pub(crate) fn convert_window_pixels(pixels: &mut [u8]) { pub(crate) fn convert_window_pixels(pixels: &mut [u8]) {
// Compile the rounded, saturated conversion into 64 KiB of read-only data.
// Capture threads share this table without allocating or dividing per pixel.
static STRAIGHT_CHANNELS: [[u8; 256]; 256] = {
let mut values = [[0; 256]; 256];
let mut alpha = 1;
while alpha < 256 {
let mut channel = 0;
while channel < 256 {
let straight = (channel * 255 + alpha / 2) / alpha;
values[alpha][channel] = if straight > 255 { 255 } else { straight as u8 };
channel += 1;
}
alpha += 1;
}
values
};
for pixel in pixels.as_chunks_mut::<4>().0 { for pixel in pixels.as_chunks_mut::<4>().0 {
pixel.swap(0, 2); pixel.swap(0, 2);
let alpha = pixel[3] as u16; let alpha = pixel[3];
// Opaque content needs only the channel swap; avoid three divisions
// per pixel for the usual window-sharing frame.
if alpha == 255 {
continue;
}
if alpha == 0 {
pixel[..3].fill(0);
continue;
}
let channels = &STRAIGHT_CHANNELS[usize::from(alpha)];
for channel in &mut pixel[..3] { for channel in &mut pixel[..3] {
*channel = (*channel as u16 * 255 + alpha / 2) *channel = channels[usize::from(*channel)];
.checked_div(alpha)
.unwrap_or(0)
.min(255) as u8;
} }
} }
} }
@@ -195,6 +218,93 @@ pub(crate) fn convert_window_pixels(pixels: &mut [u8]) {
mod tests { mod tests {
use super::*; use super::*;
#[test]
#[ignore = "manual release benchmark for window-capture pixel conversion"]
fn profile_window_conversion() {
use std::{
hint::black_box,
time::{Duration, Instant},
};
for (width, height) in [(1920usize, 1080usize), (3840, 2160)] {
for workload in ["opaque", "edges", "translucent", "mixed"] {
let mut source = vec![0u8; width * height * 4];
for (i, pixel) in source.chunks_exact_mut(4).enumerate() {
let alpha: u8 = if workload == "mixed" {
i.wrapping_mul(37) as u8
} else if workload == "translucent"
|| (workload == "edges"
&& (i % width < 16
|| i % width >= width - 16
|| i / width < 16
|| i / width >= height - 16))
{
128
} else {
255
};
let color = (i as u32).wrapping_mul(1664525).wrapping_add(1013904223);
let premultiplied =
|shift: u32| (((color >> shift) & 255) * u32::from(alpha) / 255) as u8;
pixel.copy_from_slice(&[
premultiplied(0),
premultiplied(8),
premultiplied(16),
alpha,
]);
}
let mut pixels = source.clone();
for run in 0..=3 {
let mut elapsed = Duration::ZERO;
for _ in 0..3 {
pixels.copy_from_slice(&source);
let started = Instant::now();
convert_window_pixels(black_box(&mut pixels));
elapsed += started.elapsed();
black_box(&pixels);
}
if run != 0 {
eprintln!(
"window_conversion_benchmark width={width} workload={workload} run={run} frames=3 elapsed_us={}",
elapsed.as_micros()
);
}
}
}
}
}
#[test]
fn conversion_matches_straight_argb_for_every_alpha_and_channel_value() {
let mut pixels = Vec::new();
let mut expected = Vec::new();
for alpha in 0..=255u8 {
for channel in 0..=255u8 {
let source = [channel, 255 - channel, channel ^ alpha, alpha];
pixels.extend_from_slice(&source);
let straight = |value: u8| {
if alpha == 0 {
0
} else {
((u32::from(value) * 255 + u32::from(alpha) / 2) / u32::from(alpha))
.min(255) as u8
}
};
expected.extend_from_slice(&[
straight(source[2]),
straight(source[1]),
straight(source[0]),
alpha,
]);
}
}
// Incomplete trailing pixels are outside the conversion's input domain.
pixels.extend_from_slice(&[17, 29, 43]);
expected.extend_from_slice(&[17, 29, 43]);
convert_window_pixels(&mut pixels);
assert_eq!(pixels, expected);
}
#[test] #[test]
fn readback_conversion_preserves_color_and_handles_zero_alpha() { fn readback_conversion_preserves_color_and_handles_zero_alpha() {
let mut pixels = vec![10, 20, 30, 255, 32, 64, 128, 128, 9, 8, 7, 0, 1, 1, 1, 1]; let mut pixels = vec![10, 20, 30, 255, 32, 64, 128, 128, 9, 8, 7, 0, 1, 1, 1, 1];
@@ -207,3 +317,481 @@ mod tests {
); );
} }
} }
#[derive(Default)]
struct StreamResources {
textures: std::collections::VecDeque<GlesTexture>,
bytes: usize,
straight: Option<smithay::backend::renderer::gles::GlesTexProgram>,
copy: Option<smithay::backend::renderer::gles::GlesTexProgram>,
}
impl StreamResources {
fn take_texture(&mut self, size: Size<i32, smithay::utils::Buffer>) -> Option<GlesTexture> {
let index = self
.textures
.iter()
.position(|texture| texture.size() == size)?;
let texture = self.textures.remove(index)?;
self.bytes -= texture.size().w as usize * texture.size().h as usize * 4;
Some(texture)
}
fn put_texture(&mut self, texture: GlesTexture) {
let bytes = texture.size().w as usize * texture.size().h as usize * 4;
// Scratch targets are shared by size across sources. Keep alternating
// window sizes from reallocating every frame without retaining an
// unbounded collection of old source sizes.
const MAX_BYTES: usize = 128 * 1024 * 1024;
if bytes > MAX_BYTES {
return;
}
while self.textures.len() >= 8 || self.bytes + bytes > MAX_BYTES {
let Some(old) = self.textures.pop_front() else {
break;
};
self.bytes -= old.size().w as usize * old.size().h as usize * 4;
}
self.bytes += bytes;
self.textures.push_back(texture);
}
}
#[derive(Default)]
struct StreamCache(std::sync::Mutex<StreamResources>);
const STREAM_CAPTURE_SHADER: &str = r#"
//_DEFINES
#ifdef EXTERNAL
#extension GL_OES_EGL_image_external : require
#endif
precision highp float;
#ifdef EXTERNAL
uniform samplerExternalOES tex;
#else
uniform sampler2D tex;
#endif
varying vec2 v_coords;
uniform float alpha;
uniform float y_offset;
void main() {
// The renderer negates Y for inverted textures; translate that origin
// back into [0, 1] so full-buffer captures sample both rows correctly.
vec4 pixel = texture2D(tex, v_coords + vec2(0.0, y_offset));
#ifdef NO_ALPHA
pixel.a = 1.0;
#endif
#ifdef STRAIGHT
float a = floor(pixel.a * 255.0 + 0.5);
vec3 numerator = floor(pixel.rgb * 255.0 + 0.5) * 255.0 + floor(a * 0.5);
vec3 quotient = floor(numerator / max(a, 1.0));
vec3 remainder = numerator - quotient * max(a, 1.0);
quotient += step(vec3(max(a, 1.0)), remainder);
quotient -= vec3(lessThan(remainder, vec3(0.0)));
gl_FragColor = vec4(a == 0.0 ? vec3(0.0) : clamp(quotient, 0.0, 255.0) / 255.0, 1.0);
#else
gl_FragColor = vec4(pixel.rgb, 1.0);
#endif
}
"#;
pub(crate) fn capture_stream_window(
renderer: &mut GlesRenderer,
state: &Ferese,
info: &crate::handlers::window_capture::WindowInfo,
cursor: Option<Rectangle<i32, Logical>>,
buffer: &smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer,
) -> Result<smithay::backend::renderer::sync::SyncPoint, String> {
let cache = renderer
.egl_context()
.user_data()
.get_or_insert_threadsafe(|| Arc::new(StreamCache::default()))
.clone();
let reusable = cache
.0
.lock()
.expect("stream texture cache mutex poisoned")
.take_texture({
let size = info.geometry.size.to_physical_precise_round(info.scale);
(size.w, size.h).into()
});
let mut snapshot = render_window_texture(
renderer,
&info.window,
info.geometry,
info.scale,
128 * 1024 * 1024,
reusable,
)
.map_err(|error| error.to_string())?
.ok_or("Window has no capturable content")?;
let size = snapshot.texture.size();
if let Some(cursor) = cursor {
let elements = cursor_elements(state, renderer, cursor, info.scale);
let mut target = renderer
.bind(&mut snapshot.texture)
.map_err(|error| error.to_string())?;
let mut frame = renderer
.render(&mut target, (size.w, size.h).into(), Transform::Normal)
.map_err(|error| error.to_string())?;
draw_render_elements(
&mut frame,
info.scale,
&elements,
&[Rectangle::from_size((size.w, size.h).into())],
)
.map_err(|error| error.to_string())?;
let _ = frame.finish().map_err(|error| error.to_string())?;
}
let result =
capture_stream_texture(renderer, &snapshot.texture, buffer, Transform::Normal, true);
cache
.0
.lock()
.expect("stream texture cache mutex poisoned")
.put_texture(snapshot.texture);
result
}
pub(crate) fn capture_stream_texture(
renderer: &mut GlesRenderer,
texture: &GlesTexture,
buffer: &smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer,
transform: Transform,
straight: bool,
) -> Result<smithay::backend::renderer::sync::SyncPoint, String> {
use smithay::backend::renderer::sync::SyncPoint;
use smithay::wayland::dmabuf::get_dmabuf;
use smithay::wayland::shm::with_buffer_contents_mut;
let cache = renderer
.egl_context()
.user_data()
.get_or_insert_threadsafe(|| Arc::new(StreamCache::default()))
.clone();
let mut cache = cache.0.lock().expect("stream texture cache mutex poisoned");
let program = if straight {
&mut cache.straight
} else {
&mut cache.copy
};
if program.is_none() {
*program =
Some(compile_stream_program(renderer, straight).map_err(|error| error.to_string())?);
}
let program = program.as_ref().expect("stream shader compiled").clone();
let source_size = texture.size();
let size: Size<i32, Physical> = transform.transform_size((source_size.w, source_size.h).into());
let draw = |renderer: &mut GlesRenderer,
target: &mut smithay::backend::renderer::gles::GlesTarget<'_>| {
draw_stream_texture(renderer, texture, target, transform, &program)
};
if let Ok(dmabuf) = get_dmabuf(buffer) {
let mut dmabuf = dmabuf.clone();
let mut target = renderer
.bind(&mut dmabuf)
.map_err(|error| error.to_string())?;
return draw(renderer, &mut target);
}
let valid = with_buffer_contents_mut(buffer, |_, length, data| {
data.width == size.w
&& data.height == size.h
&& data.format == smithay::reexports::wayland_server::protocol::wl_shm::Format::Xrgb8888
&& data.stride >= size.w * 4
&& data.offset >= 0
&& usize::try_from(
i64::from(data.offset) + i64::from(data.stride) * i64::from(data.height),
)
.is_ok_and(|required| required <= length)
})
.unwrap_or(false);
if !valid {
return Err("Invalid stream SHM buffer".into());
}
let mut texture = match cache.take_texture((size.w, size.h).into()) {
Some(texture) => texture,
None => Offscreen::<GlesTexture>::create_buffer(
renderer,
Fourcc::Abgr8888,
(size.w, size.h).into(),
)
.map_err(|error| error.to_string())?,
};
{
let mut target = renderer
.bind(&mut texture)
.map_err(|error| error.to_string())?;
let _ = draw(renderer, &mut target)?;
}
let mapping = renderer
.copy_texture(
&texture,
Rectangle::from_size((size.w, size.h).into()),
Fourcc::Argb8888,
)
.map_err(|error| error.to_string())?;
let source = renderer
.map_texture(&mapping)
.map_err(|error| error.to_string())?;
let row = size.w as usize * 4;
let bytes = row * size.h as usize;
if source.len() < bytes {
return Err("Incomplete stream readback".into());
}
with_buffer_contents_mut(buffer, |destination, _, data| {
for (y, pixels) in source[..bytes].chunks_exact(row).enumerate() {
// SAFETY: the buffer bounds and stride were checked above. Source
// memory belongs to the renderer and cannot overlap the client SHM.
unsafe {
std::ptr::copy_nonoverlapping(
pixels.as_ptr(),
destination.add(data.offset as usize + y * data.stride as usize),
row,
);
}
}
})
.map_err(|error| format!("{error:?}"))?;
cache.put_texture(texture);
Ok(SyncPoint::signaled())
}
fn compile_stream_program(
renderer: &mut GlesRenderer,
straight: bool,
) -> Result<smithay::backend::renderer::gles::GlesTexProgram, GlesError> {
use smithay::backend::renderer::gles::{UniformName, UniformType};
let source = if straight {
format!("#define STRAIGHT\n{STREAM_CAPTURE_SHADER}")
} else {
STREAM_CAPTURE_SHADER.to_string()
};
renderer
.compile_custom_texture_shader(source, &[UniformName::new("y_offset", UniformType::_1f)])
}
fn draw_stream_texture(
renderer: &mut GlesRenderer,
texture: &GlesTexture,
target: &mut smithay::backend::renderer::gles::GlesTarget<'_>,
transform: Transform,
program: &smithay::backend::renderer::gles::GlesTexProgram,
) -> Result<smithay::backend::renderer::sync::SyncPoint, String> {
let source_size = texture.size();
let size: Size<i32, Physical> = transform.transform_size((source_size.w, source_size.h).into());
let mut frame = renderer
.render(target, size, Transform::Normal)
.map_err(|error| error.to_string())?;
let rect = Rectangle::from_size(size);
frame
.clear(Color32F::new(0., 0., 0., 1.), &[rect])
.map_err(|error| error.to_string())?;
frame
.render_texture_from_to(
texture,
Rectangle::from_size(source_size).to_f64(),
rect,
&[rect],
&[],
transform,
1.0,
Some(program),
&[smithay::backend::renderer::gles::Uniform::new(
"y_offset",
if texture.is_y_inverted() {
1.0f32
} else {
0.0f32
},
)],
)
.map_err(|error| error.to_string())?;
frame.finish().map_err(|error| error.to_string())
}
pub(crate) fn clear_stream_capture_targets(renderer: &mut GlesRenderer) {
if let Some(cache) = renderer.egl_context().user_data().get::<Arc<StreamCache>>() {
let mut cache = cache.0.lock().expect("stream texture cache mutex poisoned");
cache.textures.clear();
cache.bytes = 0;
}
}
#[cfg(test)]
mod stream_gpu_tests {
use super::*;
use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay};
use smithay::backend::renderer::ImportMem;
#[test]
#[ignore = "requires an EGL rendering device"]
fn straight_capture_shader_matches_every_alpha_and_channel_value() {
let device = EGLDevice::enumerate()
.unwrap()
.last()
.expect("an EGL rendering device");
// SAFETY: the display retains the enumerated device for this test.
let display = unsafe { EGLDisplay::new(device).unwrap() };
let context = EGLContext::new(&display).unwrap();
// SAFETY: this thread exclusively owns the fresh context.
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
let mut pixels = Vec::with_capacity(256 * 256 * 4);
for alpha in 0..=255u8 {
for channel in 0..=255u8 {
pixels.extend_from_slice(&[
channel,
channel.wrapping_add(85),
channel.wrapping_add(170),
alpha,
]);
}
}
let source = renderer
.import_memory(&pixels, Fourcc::Abgr8888, (256, 256).into(), false)
.unwrap();
let program = compile_stream_program(&mut renderer, true).unwrap();
let mut destination = Offscreen::<GlesTexture>::create_buffer(
&mut renderer,
Fourcc::Abgr8888,
(256, 256).into(),
)
.unwrap();
{
let mut target = renderer.bind(&mut destination).unwrap();
let _ = draw_stream_texture(
&mut renderer,
&source,
&mut target,
Transform::Normal,
&program,
)
.unwrap();
}
let mapping = renderer
.copy_texture(
&destination,
Rectangle::from_size((256, 256).into()),
Fourcc::Argb8888,
)
.unwrap();
let actual = renderer.map_texture(&mapping).unwrap();
convert_window_pixels(&mut pixels);
for (index, (actual, expected)) in actual
.chunks_exact(4)
.zip(pixels.chunks_exact(4))
.enumerate()
{
assert_eq!(
&actual[..3],
&expected[..3],
"alpha={} channel={}",
index / 256,
index % 256
);
assert_eq!(actual[3], 255, "BGRx capture must be opaque");
}
}
#[test]
#[ignore = "requires an EGL rendering device"]
fn stream_transfer_matches_output_orientation_for_all_transforms() {
let device = EGLDevice::enumerate()
.unwrap()
.last()
.expect("an EGL rendering device");
// SAFETY: the renderer retains the enumerated device's display.
let display = unsafe { EGLDisplay::new(device).unwrap() };
let context = EGLContext::new(&display).unwrap();
// SAFETY: this test exclusively owns the renderer's context.
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
use Transform::*;
let cases = [
(Normal, "abcdef"),
(_90, "daebfc"),
(_180, "fedcba"),
(_270, "cfbead"),
(Flipped, "cbafed"),
(Flipped90, "adbecf"),
(Flipped180, "defabc"),
(Flipped270, "fcebda"),
];
let program = compile_stream_program(&mut renderer, false).unwrap();
let pixels: Vec<_> = b"abcdef"
.iter()
.flat_map(|value| [*value, 0, 0, 255])
.collect();
for flipped in [false, true] {
let source = renderer
.import_memory(&pixels, Fourcc::Abgr8888, (3, 2).into(), flipped)
.unwrap();
for (transform, expected) in cases {
let size =
transform.transform_size(Size::<i32, smithay::utils::Buffer>::from((3, 2)));
let mut target =
Offscreen::<GlesTexture>::create_buffer(&mut renderer, Fourcc::Abgr8888, size)
.unwrap();
{
let mut target = renderer.bind(&mut target).unwrap();
let _ = draw_stream_texture(
&mut renderer,
&source,
&mut target,
transform,
&program,
)
.unwrap();
}
let mapping = renderer
.copy_texture(&target, Rectangle::from_size(size), Fourcc::Abgr8888)
.unwrap();
let actual = renderer.map_texture(&mapping).unwrap();
let actual: Vec<_> = actual.chunks_exact(4).map(|pixel| pixel[0]).collect();
// A flipped texture describes the same image with reversed source
// rows. Derive that reference before applying the output transform.
let expected = if flipped {
expected
.bytes()
.map(|value| if value < b'd' { value + 3 } else { value - 3 })
.collect::<Vec<_>>()
} else {
expected.as_bytes().to_vec()
};
assert_eq!(actual, expected, "{transform:?}, texture flipped={flipped}");
}
}
}
#[test]
#[ignore = "requires an EGL rendering device"]
fn alternating_capture_sizes_reuse_their_gpu_allocations() {
let device = EGLDevice::enumerate()
.unwrap()
.last()
.expect("an EGL rendering device");
// SAFETY: the renderer retains the enumerated device's display.
let display = unsafe { EGLDisplay::new(device).unwrap() };
let context = EGLContext::new(&display).unwrap();
// SAFETY: this test exclusively owns the renderer's context.
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
let mut cache = StreamResources::default();
let sizes = [Size::from((64, 48)), Size::from((96, 64))];
let mut allocations = [None, None];
for _ in 0..100 {
for (index, size) in sizes.into_iter().enumerate() {
let texture = cache.take_texture(size).unwrap_or_else(|| {
Offscreen::<GlesTexture>::create_buffer(&mut renderer, Fourcc::Abgr8888, size)
.unwrap()
});
if let Some(previous) = allocations[index] {
assert_eq!(
texture.tex_id(),
previous,
"switching source sizes allocated another GPU target"
);
} else {
allocations[index] = Some(texture.tex_id());
}
cache.put_texture(texture);
}
}
assert_eq!(cache.textures.len(), 2);
assert_eq!(cache.bytes, (64 * 48 + 96 * 64) * 4);
}
}
+3 -3
View File
@@ -20,7 +20,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::time::Duration;
pub(crate) use capture::{ pub(crate) use capture::{
capture_resize_snapshot, capture_window_buffer, capture_window_frame, convert_window_pixels, capture_resize_snapshot, capture_stream_texture, capture_stream_window, capture_window_buffer,
capture_window_frame, clear_stream_capture_targets, convert_window_pixels,
}; };
use decorations::{ use decorations::{
border_uniforms, color_with_alpha, corner_program, framebuffer_clip_rect, border_uniforms, color_with_alpha, corner_program, framebuffer_clip_rect,
@@ -37,8 +38,7 @@ use overview::{overview_chrome_element, overview_strip_elements};
pub(crate) use resources::RenderResources; pub(crate) use resources::RenderResources;
use scene::cursor_elements; use scene::cursor_elements;
pub(crate) use scene::{ pub(crate) use scene::{
animated_window_elements, capture_output_elements, frame_effect_metrics, layer_surfaces, capture_output_elements, frame_effect_metrics, layer_surfaces, sampled_output_elements,
sampled_output_elements,
}; };
pub(crate) use shader::SharedPixelShaderElement; pub(crate) use shader::SharedPixelShaderElement;
use smithay::backend::allocator::Fourcc; use smithay::backend::allocator::Fourcc;
+9 -29
View File
@@ -7,26 +7,17 @@ use smithay::backend::renderer::utils::CommitCounter;
use super::{ use super::{
AnimatedWindowRenderElement, ConstrainAlign, ConstrainBehavior, ConstrainReference, AnimatedWindowRenderElement, ConstrainAlign, ConstrainBehavior, ConstrainReference,
CropRenderElement, CursorImageStatus, CursorImageSurfaceData, Duration, Ferese, CropRenderElement, CursorImageStatus, CursorImageSurfaceData, Ferese, FrameEffectMetrics,
FrameEffectMetrics, GlesRenderer, Layer, LayerSurface, Logical, MaterialSurface, GlesRenderer, Layer, LayerSurface, Logical, MaterialSurface, MemoryRenderBufferRenderElement,
MemoryRenderBufferRenderElement, Output, Physical, Point, PopupManager, Rectangle, Relocate, Output, Physical, Point, PopupManager, Rectangle, Relocate, RelocateRenderElement,
RelocateRenderElement, RenderElementKind, Renderer, RescaleRenderElement, Resource, RenderElementKind, Renderer, RescaleRenderElement, Resource, RoundedRect, Size,
RoundedRect, Size, SolidColorRenderElement, Transform, WaylandSurfaceRenderElement, SolidColorRenderElement, Transform, WaylandSurfaceRenderElement, append_material_surface,
append_material_surface, backdrop, constrain_space_element, corner_program, backdrop, constrain_space_element, corner_program, layer_map_for_output, material_element,
layer_map_for_output, material_element, overview_strip_elements, overview_strip_elements, render_elements_from_surface_tree, resize_content_behavior,
render_elements_from_surface_tree, resize_content_behavior, rounded_visual_rect, rounded_visual_rect, rounded_window_elements, window_border_element, window_corner_shape,
rounded_window_elements, window_border_element, window_corner_shape, window_shadow_element, window_shadow_element, window_tint_element, with_states,
window_tint_element, with_states,
}; };
pub(crate) fn animated_window_elements(
state: &mut Ferese,
renderer: &mut GlesRenderer,
output: &Output,
) -> Vec<AnimatedWindowRenderElement> {
output_elements(state, renderer, output, true)
}
pub(crate) fn frame_effect_metrics( pub(crate) fn frame_effect_metrics(
_elements: &[AnimatedWindowRenderElement], _elements: &[AnimatedWindowRenderElement],
_scale: f64, _scale: f64,
@@ -34,17 +25,6 @@ pub(crate) fn frame_effect_metrics(
FrameEffectMetrics FrameEffectMetrics
} }
pub(crate) fn output_elements(
state: &mut Ferese,
renderer: &mut GlesRenderer,
output: &Output,
include_cursor: bool,
) -> Vec<AnimatedWindowRenderElement> {
let frame = state.sample_frame(output, Duration::ZERO);
sampled_output_elements(state, renderer, output, include_cursor, &frame)
}
pub(crate) fn sampled_output_elements( pub(crate) fn sampled_output_elements(
state: &mut Ferese, state: &mut Ferese,
renderer: &mut GlesRenderer, renderer: &mut GlesRenderer,
+1 -1
View File
@@ -894,7 +894,7 @@ fn ipc_events_follow_lock_lifecycle_without_exposing_desktop_objects() {
fixture.state.publish_ipc_events(); fixture.state.publish_ipc_events();
let snapshot = fixture.state.ipc_event_snapshot(); let snapshot = fixture.state.ipc_event_snapshot();
assert!(snapshot.workspaces.as_array().unwrap().is_empty()); assert!(snapshot.workspaces.as_array().unwrap().is_empty());
assert!(snapshot.windows.as_array().unwrap().is_empty()); assert!(snapshot.windows.is_empty());
assert!(snapshot.focus.is_null()); assert!(snapshot.focus.is_null());
assert_eq!(snapshot.lock.phase, "acquiring"); assert_eq!(snapshot.lock.phase, "acquiring");
assert!(receive.try_iter().any( assert!(receive.try_iter().any(
+5 -2
View File
@@ -458,8 +458,9 @@ pub struct Ferese {
pub(crate) render: crate::render::RenderResources, pub(crate) render: crate::render::RenderResources,
pub(crate) capture_render: crate::render::RenderResources, pub(crate) capture_render: crate::render::RenderResources,
pub(crate) capture_last_used: Option<Instant>, pub(crate) capture_last_used: Option<Instant>,
pub(crate) nested_capture_texture: Option<smithay::backend::renderer::gles::GlesTexture>, pub(crate) nested_capture_texture: [Option<crate::backends::direct::capture::CaptureTarget>; 2],
pub(crate) capture_epoch: u64, pub(crate) capture_epoch: u64,
pub(crate) image_capture: crate::handlers::image_capture::CaptureSessions,
capture_protected_windows: HashSet<WindowId>, capture_protected_windows: HashSet<WindowId>,
capture_protected_cursor: Option<WlSurface>, capture_protected_cursor: Option<WlSurface>,
pub(crate) daemons: Option<std::rc::Rc<std::cell::RefCell<crate::daemon::Runner>>>, pub(crate) daemons: Option<std::rc::Rc<std::cell::RefCell<crate::daemon::Runner>>>,
@@ -733,6 +734,7 @@ impl Ferese {
let mut activities = crate::activities::Engine::default(); let mut activities = crate::activities::Engine::default();
activities.configure(&config.activity_settings, config.activity_matcher.clone()); activities.configure(&config.activity_settings, config.activity_matcher.clone());
let image_capture = crate::handlers::image_capture::CaptureSessions::new(&display_handle);
let mut state = Self { let mut state = Self {
session_lock_state, session_lock_state,
session_lock: crate::session_lock::Lock::default(), session_lock: crate::session_lock::Lock::default(),
@@ -778,8 +780,9 @@ impl Ferese {
render: Default::default(), render: Default::default(),
capture_render: Default::default(), capture_render: Default::default(),
capture_last_used: None, capture_last_used: None,
nested_capture_texture: None, nested_capture_texture: Default::default(),
capture_epoch: 0, capture_epoch: 0,
image_capture,
capture_protected_windows: HashSet::new(), capture_protected_windows: HashSet::new(),
capture_protected_cursor: None, capture_protected_cursor: None,
display_presentation: Default::default(), display_presentation: Default::default(),
+11
View File
@@ -191,6 +191,17 @@ impl Ferese {
return false; return false;
} }
// Mark before advancing so the final settling frame invalidates capture
// too, even if output_has_animations becomes false during this tick.
for output in self.space.outputs() {
if crate::backends::direct::capture::changes::tracked(output)
&& (self.output_has_animations(output)
|| self.output_has_pending_visual_changes(output))
{
crate::backends::direct::capture::changes::content_changed(output);
}
}
let delta = delta.mul_f64(self.animation_speed); let delta = delta.mul_f64(self.animation_speed);
if let Some(workspace) = self if let Some(workspace) = self
.focus_swipe() .focus_swipe()
+10 -2
View File
@@ -296,7 +296,12 @@ fn pixels(
output.clone(), output.clone(),
Rectangle::from_size((320, 240).into()), Rectangle::from_size((320, 240).into()),
)); ));
let mut target = Some(texture.clone()); // Retain damage history as well as pixels: changing privacy policy
// must erase content already present in the reused capture target.
let mut target = std::mem::take(&mut state.nested_capture_texture);
target[0].get_or_insert_with(|| {
crate::backends::direct::capture::CaptureTarget::new(texture.clone(), output)
});
crate::backends::direct::capture::capture_output( crate::backends::direct::capture::capture_output(
state, state,
renderer, renderer,
@@ -304,6 +309,7 @@ fn pixels(
output, output,
&scene, &scene,
); );
state.nested_capture_texture = target;
let mut result = receiver.try_recv().unwrap().result.unwrap().pixels; let mut result = receiver.try_recv().unwrap().result.unwrap().pixels;
for pixel in result.chunks_exact_mut(4) { for pixel in result.chunks_exact_mut(4) {
pixel.swap(0, 2); pixel.swap(0, 2);
@@ -749,13 +755,15 @@ fn capture_privacy_pixels_and_policy_transitions() {
Rectangle::new((32, 24).into(), (160, 120).into()), Rectangle::new((32, 24).into(), (160, 120).into()),
)); ));
let scene = state.sample_frame(&output, Duration::ZERO); let scene = state.sample_frame(&output, Duration::ZERO);
let mut target = std::mem::take(&mut state.nested_capture_texture);
crate::backends::direct::capture::capture_output( crate::backends::direct::capture::capture_output(
&mut state, &mut state,
&mut renderer, &mut renderer,
&mut Some(capture_texture.clone()), &mut target,
&output, &output,
&scene, &scene,
); );
state.nested_capture_texture = target;
let region = receiver.try_recv().unwrap().result.unwrap(); let region = receiver.try_recv().unwrap().result.unwrap();
assert_eq!((region.width, region.height), (160, 120)); assert_eq!((region.width, region.height), (160, 120));
assert!( assert!(
+1
View File
@@ -2,6 +2,7 @@ use super::{DesktopOutput, Ferese, Logical, Output, OutputId, Point};
impl Ferese { impl Ferese {
pub(crate) fn defer_output_redraw(&mut self, output: Output) { pub(crate) fn defer_output_redraw(&mut self, output: Output) {
crate::backends::direct::capture::changes::content_changed(&output);
if !self.output_redraw_pending.contains(&output) { if !self.output_redraw_pending.contains(&output) {
self.output_redraw_pending.push(output); self.output_redraw_pending.push(output);
} }
+4 -4
View File
@@ -16,7 +16,7 @@ use smithay::utils::{Physical, Rectangle, Size, Transform};
use crate::Ferese; use crate::Ferese;
use crate::metrics::{FrameEffectMetrics, RenderMetrics}; use crate::metrics::{FrameEffectMetrics, RenderMetrics};
use crate::render::{animated_window_elements, frame_effect_metrics, layer_surfaces}; use crate::render::{frame_effect_metrics, layer_surfaces, sampled_output_elements};
pub(crate) type NestedBackend = Rc<RefCell<winit::WinitGraphicsBackend<GlesRenderer>>>; pub(crate) type NestedBackend = Rc<RefCell<winit::WinitGraphicsBackend<GlesRenderer>>>;
type DamageRenderResult = Result< type DamageRenderResult = Result<
@@ -144,7 +144,8 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
let rendered = (|| -> DamageRenderResult { let rendered = (|| -> DamageRenderResult {
{ {
let (renderer, mut framebuffer) = backend.bind()?; let (renderer, mut framebuffer) = backend.bind()?;
let elements = animated_window_elements(state, renderer, &output); let scene = state.sample_frame(&output, Duration::ZERO);
let elements = sampled_output_elements(state, renderer, &output, true, &scene);
let effects = frame_effect_metrics(&elements, output.current_scale().fractional_scale()); let effects = frame_effect_metrics(&elements, output.current_scale().fractional_scale());
let result = damage_tracker.render_output( let result = damage_tracker.render_output(
renderer, renderer,
@@ -164,8 +165,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
if state.has_pending_screencopy(&output, false) if state.has_pending_screencopy(&output, false)
|| state.has_pending_screencopy(&output, true) || state.has_pending_screencopy(&output, true)
{ {
let scene = state.sample_frame(&output, Duration::ZERO); let mut capture_texture = std::mem::take(&mut state.nested_capture_texture);
let mut capture_texture = state.nested_capture_texture.take();
crate::backends::direct::capture::capture_output( crate::backends::direct::capture::capture_output(
state, state,
renderer, renderer,
+4 -2
View File
@@ -10,8 +10,8 @@ workspace = true
[features] [features]
default = ["client", "server"] default = ["client", "server"]
client = ["dep:wayland-client", "wayland-protocols-wlr/client"] client = ["dep:wayland-client", "wayland-protocols-wlr/client", "wayland-protocols/client"]
server = ["dep:wayland-server", "wayland-protocols-wlr/server"] server = ["dep:wayland-server", "wayland-protocols-wlr/server", "wayland-protocols/server"]
[dependencies] [dependencies]
bitflags.workspace = true bitflags.workspace = true
@@ -20,3 +20,5 @@ wayland-client = { workspace = true, optional = true }
wayland-scanner.workspace = true wayland-scanner.workspace = true
wayland-server = { workspace = true, optional = true } wayland-server = { workspace = true, optional = true }
wayland-protocols-wlr.workspace = true wayland-protocols-wlr.workspace = true
wayland-protocols = { version = "0.32", features = ["staging"] }
+4
View File
@@ -195,10 +195,12 @@ pub mod window_capture {
pub mod client { pub mod client {
use wayland_client; use wayland_client;
use wayland_client::protocol::*; use wayland_client::protocol::*;
use wayland_protocols::ext::image_capture_source::v1::client::*;
use wayland_protocols_wlr::screencopy::v1::client::*; use wayland_protocols_wlr::screencopy::v1::client::*;
pub mod __interfaces { pub mod __interfaces {
use wayland_client::protocol::__interfaces::*; use wayland_client::protocol::__interfaces::*;
use wayland_protocols::ext::image_capture_source::v1::client::__interfaces::*;
use wayland_protocols_wlr::screencopy::v1::client::__interfaces::*; use wayland_protocols_wlr::screencopy::v1::client::__interfaces::*;
wayland_scanner::generate_interfaces!( wayland_scanner::generate_interfaces!(
@@ -214,11 +216,13 @@ pub mod window_capture {
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub mod server { pub mod server {
use wayland_protocols::ext::image_capture_source::v1::server::*;
use wayland_protocols_wlr::screencopy::v1::server::*; use wayland_protocols_wlr::screencopy::v1::server::*;
use wayland_server; use wayland_server;
use wayland_server::protocol::*; use wayland_server::protocol::*;
pub mod __interfaces { pub mod __interfaces {
use wayland_protocols::ext::image_capture_source::v1::server::__interfaces::*;
use wayland_protocols_wlr::screencopy::v1::server::__interfaces::*; use wayland_protocols_wlr::screencopy::v1::server::__interfaces::*;
use wayland_server::protocol::__interfaces::*; use wayland_server::protocol::__interfaces::*;
+3 -1
View File
@@ -40,6 +40,8 @@ serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync", "process", "io-util", "net"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync", "process", "io-util", "net"] }
wayland-client.workspace = true wayland-client.workspace = true
wayland-protocols = { version = "0.32", features = ["client", "unstable"] } wayland-protocols = { version = "0.32", features = ["client", "unstable", "staging"] }
wayland-protocols-wlr.workspace = true wayland-protocols-wlr.workspace = true
zbus = { version = "5.19.0", default-features = false, features = ["tokio"] } zbus = { version = "5.19.0", default-features = false, features = ["tokio"] }
gbm = { version = "0.18", default-features = false }
+8 -258
View File
@@ -1,19 +1,13 @@
use std::fs::File; use std::os::fd::AsRawFd;
use std::os::fd::{AsFd, AsRawFd, FromRawFd};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use ferese_protocols::window_capture::v1::client::ferese_window_capture_manager_v1 as window_manager; use ferese_protocols::window_capture::v1::client::ferese_window_capture_manager_v1 as window_manager;
use memmap2::MmapMut;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use wayland_client::protocol::{wl_buffer, wl_output, wl_registry, wl_shm, wl_shm_pool}; use wayland_client::protocol::{wl_output, wl_registry, wl_shm};
use wayland_client::{Connection, Dispatch, EventQueue, QueueHandle, WEnum}; use wayland_client::{Connection, Dispatch, EventQueue, QueueHandle, WEnum};
use wayland_protocols_wlr::screencopy::v1::client::{ use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1 as manager;
zwlr_screencopy_frame_v1 as frame, zwlr_screencopy_manager_v1 as manager,
};
pub const BUSY: &str = "Window capture snapshot pool is busy";
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Source { pub struct Source {
@@ -34,53 +28,23 @@ impl Source {
struct Output { struct Output {
global: u32, global: u32,
proxy: wl_output::WlOutput, _proxy: wl_output::WlOutput,
source: Source, source: Source,
transform: wl_output::Transform, transform: wl_output::Transform,
} }
pub struct Frame {
pub logical_size: Option<(u32, u32)>,
pub width: u32,
pub height: u32,
pub stride: u32,
pub pixels: Vec<u8>,
}
struct Buffer {
_file: File,
map: MmapMut,
proxy: wl_buffer::WlBuffer,
width: u32,
height: u32,
stride: u32,
format: wl_shm::Format,
}
impl Drop for Buffer {
fn drop(&mut self) {
self.proxy.destroy();
}
}
#[derive(Default)] #[derive(Default)]
struct State { struct State {
outputs: Vec<Output>, outputs: Vec<Output>,
manager: Option<manager::ZwlrScreencopyManagerV1>, manager: Option<manager::ZwlrScreencopyManagerV1>,
window_manager: Option<window_manager::FereseWindowCaptureManagerV1>, window_manager: Option<window_manager::FereseWindowCaptureManagerV1>,
shm: Option<wl_shm::WlShm>, shm: Option<wl_shm::WlShm>,
buffer: Option<Buffer>,
result: Option<Result<(), String>>,
flipped: bool,
logical_size: Option<(u32, u32)>,
busy: bool,
} }
pub struct Capture { pub struct Capture {
connection: Connection, connection: Connection,
queue: EventQueue<State>, queue: EventQueue<State>,
state: State, state: State,
selected_output: Option<u32>,
} }
impl Capture { impl Capture {
@@ -96,7 +60,6 @@ impl Capture {
connection, connection,
queue, queue,
state: State::default(), state: State::default(),
selected_output: None,
}; };
// Two syncs deliver registry bindings and output metadata without an // Two syncs deliver registry bindings and output metadata without an
// uninterruptible roundtrip on a stalled compositor. // uninterruptible roundtrip on a stalled compositor.
@@ -134,14 +97,6 @@ impl Capture {
.map(|output| output.global) .map(|output| output.global)
} }
pub fn pin_output(&mut self, name: &str, generation: u32) -> Result<(), String> {
if self.generation(name) != Some(generation) {
return Err("Shared monitor was replaced".into());
}
self.selected_output = Some(generation);
Ok(())
}
pub fn validate_sources( pub fn validate_sources(
&mut self, &mut self,
selected: &[(String, u32)], selected: &[(String, u32)],
@@ -170,99 +125,6 @@ impl Capture {
.collect() .collect()
} }
pub fn frame(
&mut self,
name: &str,
cursor: bool,
stop: &AtomicBool,
mut pixels: Vec<u8>,
) -> Result<Frame, String> {
self.state.result = None;
self.state.flipped = false;
self.state.logical_size = None;
self.state.busy = false;
let (frame, transform) = if let Some(id) = name
.strip_prefix("window:")
.and_then(|id| id.parse::<u64>().ok())
{
let manager = self
.state
.window_manager
.as_ref()
.ok_or("Window capture is unavailable")?;
(
manager.capture_window(
(id >> 32) as u32,
id as u32,
cursor as u32,
&self.queue.handle(),
(),
),
wl_output::Transform::Normal,
)
} else {
let output = self
.state
.outputs
.iter()
.find(|o| o.source.name == name)
.ok_or("Shared monitor disconnected")?;
if self
.selected_output
.is_some_and(|selected| selected != output.global)
{
return Err("Shared monitor was replaced".into());
}
self.selected_output = Some(output.global);
let manager = self
.state
.manager
.as_ref()
.ok_or("Monitor capture is unavailable")?;
(
manager.capture_output(cursor as i32, &output.proxy, &self.queue.handle(), ()),
output.transform,
)
};
let deadline = Instant::now() + Duration::from_secs(5);
while self.state.result.is_none() {
if let Err(error) = self.dispatch(stop, deadline) {
frame.destroy();
return Err(error);
}
}
frame.destroy();
self.state
.result
.take()
.expect("the capture dispatch loop exits after storing its result")?;
let buffer = self
.state
.buffer
.as_ref()
.ok_or("Capture supplied no buffer")?;
let (width, height) = copy_oriented_pixels(
&buffer.map,
buffer.width,
buffer.height,
buffer.stride,
transform,
self.state.flipped,
&mut pixels,
);
Ok(Frame {
logical_size: self.state.logical_size,
width,
height,
stride: width * 4,
pixels,
})
}
fn dispatch(&mut self, stop: &AtomicBool, deadline: Instant) -> Result<(), String> { fn dispatch(&mut self, stop: &AtomicBool, deadline: Instant) -> Result<(), String> {
if stop.load(Ordering::Relaxed) { if stop.load(Ordering::Relaxed) {
return Err("Capture cancelled".into()); return Err("Capture cancelled".into());
@@ -321,7 +183,7 @@ impl Dispatch<wl_registry::WlRegistry, ()> for State {
"wl_output" => state.outputs.push(Output { "wl_output" => state.outputs.push(Output {
global: name, global: name,
transform: wl_output::Transform::Normal, transform: wl_output::Transform::Normal,
proxy: registry.bind(name, version.min(4), qh, name), _proxy: registry.bind(name, version.min(4), qh, name),
source: Source { source: Source {
name: format!("output-{name}"), name: format!("output-{name}"),
label: "Display".into(), label: "Display".into(),
@@ -387,100 +249,6 @@ impl Dispatch<wl_output::WlOutput, u32> for State {
} }
} }
impl Dispatch<frame::ZwlrScreencopyFrameV1, ()> for State {
fn event(
state: &mut Self,
frame: &frame::ZwlrScreencopyFrameV1,
event: frame::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
match event {
frame::Event::Buffer {
format: WEnum::Value(format),
width,
height,
stride,
} => {
let result = (|| {
if format != wl_shm::Format::Argb8888 && format != wl_shm::Format::Xrgb8888 {
return Err("Unsupported capture pixel format".into());
}
let bytes = buffer_size(width, height, stride)?;
if let Some(buffer) = &state.buffer
&& (buffer.width, buffer.height, buffer.stride, buffer.format)
== (width, height, stride, format)
{
frame.copy(&buffer.proxy);
return Ok(());
}
// SAFETY: fixed NUL-terminated name and a newly owned memfd.
let fd = unsafe {
libc::memfd_create(c"ferese-screencast".as_ptr(), libc::MFD_CLOEXEC)
};
if fd < 0 {
return Err(std::io::Error::last_os_error().to_string());
}
// SAFETY: memfd_create succeeded above and returned a fresh descriptor whose
// ownership transfers to File.
let file = unsafe { File::from_raw_fd(fd) };
file.set_len(bytes as u64).map_err(|e| e.to_string())?;
// SAFETY: the file has the checked mapping length and lives with the mapping.
let map = unsafe { MmapMut::map_mut(&file) }.map_err(|e| e.to_string())?;
let pool = state
.shm
.as_ref()
.ok_or("Missing shared memory global")?
.create_pool(file.as_fd(), bytes as i32, qh, ());
let proxy = pool.create_buffer(
0,
width as i32,
height as i32,
stride as i32,
format,
qh,
(),
);
pool.destroy();
frame.copy(&proxy);
state.buffer = Some(Buffer {
_file: file,
map,
proxy,
width,
height,
stride,
format,
});
Ok::<(), String>(())
})();
if let Err(e) = result {
state.result = Some(Err(e));
}
}
frame::Event::Ready { .. } => state.result = Some(Ok(())),
frame::Event::Failed => {
state.result = Some(Err(if state.busy {
BUSY.into()
} else {
"Capture ended: session locked, source closed, or compositor unavailable".into()
}))
}
frame::Event::Flags {
flags: WEnum::Value(flags),
} => state.flipped = flags.contains(frame::Flags::YInvert),
_ => (),
}
}
}
fn swaps_axes(transform: wl_output::Transform) -> bool { fn swaps_axes(transform: wl_output::Transform) -> bool {
matches!( matches!(
transform, transform,
@@ -494,6 +262,7 @@ fn swaps_axes(transform: wl_output::Transform) -> bool {
// Screencopy supplies output-buffer coordinates. Normalize both the output's // Screencopy supplies output-buffer coordinates. Normalize both the output's
// presentation transform and the independent YInvert storage flag before // presentation transform and the independent YInvert storage flag before
// publishing upright, tightly packed BGRx frames to PipeWire. // publishing upright, tightly packed BGRx frames to PipeWire.
#[cfg(test)]
fn copy_oriented_pixels( fn copy_oriented_pixels(
source: &[u8], source: &[u8],
width: u32, width: u32,
@@ -545,6 +314,7 @@ fn copy_oriented_pixels(
(out_width, out_height) (out_width, out_height)
} }
#[cfg(test)]
fn buffer_size(width: u32, height: u32, stride: u32) -> Result<usize, String> { fn buffer_size(width: u32, height: u32, stride: u32) -> Result<usize, String> {
let row = width.checked_mul(4).ok_or("Capture dimensions overflow")?; let row = width.checked_mul(4).ok_or("Capture dimensions overflow")?;
let bytes = stride let bytes = stride
@@ -572,27 +342,8 @@ impl Dispatch<wayland_client::protocol::wl_callback::WlCallback, Arc<AtomicBool>
} }
wayland_client::delegate_noop!(State: ignore wl_shm::WlShm); wayland_client::delegate_noop!(State: ignore wl_shm::WlShm);
wayland_client::delegate_noop!(State: ignore wl_shm_pool::WlShmPool);
wayland_client::delegate_noop!(State: ignore wl_buffer::WlBuffer);
wayland_client::delegate_noop!(State: ignore manager::ZwlrScreencopyManagerV1); wayland_client::delegate_noop!(State: ignore manager::ZwlrScreencopyManagerV1);
impl Dispatch<window_manager::FereseWindowCaptureManagerV1, ()> for State { wayland_client::delegate_noop!(State: ignore window_manager::FereseWindowCaptureManagerV1);
fn event(
state: &mut Self,
_: &window_manager::FereseWindowCaptureManagerV1,
event: window_manager::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
window_manager::Event::Geometry { width, height, .. } => {
state.logical_size = Some((width, height))
}
window_manager::Event::Busy { .. } => state.busy = true,
_ => (),
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -622,7 +373,6 @@ mod tests {
connection, connection,
queue, queue,
state: State::default(), state: State::default(),
selected_output: None,
}; };
capture.validate_sources(&[], &flag).unwrap_err() capture.validate_sources(&[], &flag).unwrap_err()
} }
+39 -26
View File
@@ -3,6 +3,7 @@ use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use ferese_ipc::events::Window;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream; use tokio::net::UnixStream;
use tokio::sync::RwLock; use tokio::sync::RwLock;
@@ -197,7 +198,7 @@ impl Background {
// Tokio owns the socket so cancelling the watch task also closes an idle stream. // Tokio owns the socket so cancelling the watch task also closes an idle stream.
struct BackgroundEvents { struct BackgroundEvents {
stream: UnixStream, stream: UnixStream,
windows: serde_json::Value, windows: Vec<Window>,
changed: bool, changed: bool,
} }
@@ -238,7 +239,7 @@ impl BackgroundEvents {
.map_err(|_| "Event subscription timed out".to_owned())??; .map_err(|_| "Event subscription timed out".to_owned())??;
Ok(Self { Ok(Self {
stream, stream,
windows: serde_json::json!([]), windows: Vec::new(),
changed: false, changed: false,
}) })
} }
@@ -257,10 +258,9 @@ impl BackgroundEvents {
self.changed = true; self.changed = true;
} }
Change::FocusChanged { focus } => { Change::FocusChanged { focus } => {
for window in self.windows.as_array_mut().into_iter().flatten() { let focused = focus["window"].as_u64();
window["focused"] = serde_json::json!( for window in &mut self.windows {
focus["window"].is_number() && window["id"] == focus["window"] window.focused = focused == Some(window.id);
);
} }
self.changed = true; self.changed = true;
} }
@@ -364,14 +364,15 @@ async fn background_consent(app: &str, name: &str) -> Result<Option<Options>, St
)]))) )])))
} }
fn app_states(windows: &serde_json::Value) -> HashMap<String, u32> { fn app_states(windows: &[Window]) -> HashMap<String, u32> {
let mut apps: HashMap<String, u32> = HashMap::new(); let mut apps: HashMap<String, u32> = HashMap::new();
for window in windows.as_array().into_iter().flatten() { for window in windows {
let Some(app) = window["app_id"].as_str().filter(|app| !app.is_empty()) else { let app = window.app_id.as_str();
if app.is_empty() {
continue; continue;
}; }
let app = app.strip_suffix(".desktop").unwrap_or(app).to_owned(); let app = app.strip_suffix(".desktop").unwrap_or(app).to_owned();
let state = if window["focused"] == true { 2 } else { 1 }; let state = if window.focused { 2 } else { 1 };
apps.entry(app) apps.entry(app)
.and_modify(|old| *old = (*old).max(state)) .and_modify(|old| *old = (*old).max(state))
.or_insert(state); .or_insert(state);
@@ -480,6 +481,18 @@ fn desktop_argument(argument: &str) -> Result<String, String> {
mod tests { mod tests {
use super::*; use super::*;
fn window(id: u64, app: &str, focused: bool, workspace: u64) -> Window {
Window {
id,
app_id: app.into(),
title: format!("Window {id}"),
focused,
workspace,
floating: false,
fullscreen: false,
}
}
async fn send_frame(peer: &mut UnixStream, value: &impl serde::Serialize) { async fn send_frame(peer: &mut UnixStream, value: &impl serde::Serialize) {
let mut bytes = Vec::new(); let mut bytes = Vec::new();
ferese_ipc::write_frame(&mut bytes, value).unwrap(); ferese_ipc::write_frame(&mut bytes, value).unwrap();
@@ -516,10 +529,10 @@ mod tests {
desktop: Box::new(ferese_ipc::events::Snapshot { desktop: Box::new(ferese_ipc::events::Snapshot {
outputs: serde_json::json!([]), outputs: serde_json::json!([]),
workspaces: serde_json::json!([]), workspaces: serde_json::json!([]),
windows: serde_json::json!([{"id":1,"app_id":"one.desktop","focused":true}]), windows: vec![window(1, "one.desktop", true, 1)],
focus: serde_json::json!({"window":1}), focus: serde_json::json!({"window":1}),
config: Default::default(), config: Default::default(),
theme: serde_json::Value::Null, theme: serde_json::Value::Null.into(),
lock: ferese_ipc::events::LockState { lock: ferese_ipc::events::LockState {
phase: "unlocked".into(), phase: "unlocked".into(),
sleeping: false, sleeping: false,
@@ -533,10 +546,10 @@ mod tests {
events.next().await.unwrap().unwrap(), events.next().await.unwrap().unwrap(),
HashMap::from([("one".into(), 2)]) HashMap::from([("one".into(), 2)])
); );
let windows = serde_json::json!([ let windows = vec![
{"id":1,"app_id":"one.desktop","focused":true}, window(1, "one.desktop", true, 1),
{"id":2,"app_id":"two","focused":false} window(2, "two", false, 2),
]); ];
send_frame( send_frame(
&mut peer, &mut peer,
&Event { &Event {
@@ -577,7 +590,7 @@ mod tests {
generation: 2, generation: 2,
last: true, last: true,
change: Change::WindowsChanged { change: Change::WindowsChanged {
windows: serde_json::json!([]), windows: Vec::new(),
}, },
}, },
) )
@@ -595,7 +608,7 @@ mod tests {
let (stream, mut peer) = UnixStream::pair().unwrap(); let (stream, mut peer) = UnixStream::pair().unwrap();
let mut events = BackgroundEvents { let mut events = BackgroundEvents {
stream, stream,
windows: serde_json::json!([]), windows: Vec::new(),
changed: false, changed: false,
}; };
peer.write_u32((ferese_ipc::MAX_PAYLOAD_SIZE + 1) as u32) peer.write_u32((ferese_ipc::MAX_PAYLOAD_SIZE + 1) as u32)
@@ -608,17 +621,17 @@ mod tests {
#[test] #[test]
fn application_state_groups_windows_and_keeps_inactive_workspaces_running() { fn application_state_groups_windows_and_keeps_inactive_workspaces_running() {
let windows = serde_json::json!([ let windows = vec![
{"app_id":"org.test.App.desktop","focused":false,"mapped":false}, window(1, "org.test.App.desktop", false, 2),
{"app_id":"org.test.App","focused":true,"mapped":true}, window(2, "org.test.App", true, 1),
{"app_id":"org.test.Other","focused":false,"mapped":true}, window(3, "org.test.Other", false, 3),
{"app_id":"","focused":false} window(4, "", false, 1),
]); ];
assert_eq!( assert_eq!(
app_states(&windows), app_states(&windows),
HashMap::from([("org.test.App".into(), 2), ("org.test.Other".into(), 1)]) HashMap::from([("org.test.App".into(), 2), ("org.test.Other".into(), 1)])
); );
assert!(app_states(&serde_json::json!([])).is_empty()); assert!(app_states(&[]).is_empty());
} }
#[test] #[test]
+167 -8
View File
@@ -4,8 +4,8 @@ use std::io::{Read, Write};
use std::os::fd::AsRawFd; use std::os::fd::AsRawFd;
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::recording::{Audio, Recording}; use crate::recording::{Audio, Recording};
@@ -515,21 +515,30 @@ fn encode(
.is_some_and(|event| matches!(event.view(), gst::EventView::Eos(_))) .is_some_and(|event| matches!(event.view(), gst::EventView::Eos(_)))
{ {
ended.store(true, Ordering::Relaxed); ended.store(true, Ordering::Relaxed);
// Finish through the encoder queue after appending the last
// image's timestamp, even when the capture source vanished.
return gst::PadProbeReturn::Drop;
} }
gst::PadProbeReturn::Ok gst::PadProbeReturn::Ok
}); });
let announced = Arc::new(AtomicBool::new(false)); let announced = Arc::new(AtomicBool::new(false));
let notice = announced.clone(); let notice = announced.clone();
let last_frame = Arc::new(Mutex::new(None));
let observed_frame = last_frame.clone();
source source
.static_pad("src") .static_pad("src")
.ok_or("Capture has no video output")? .ok_or("Capture has no video output")?
.add_probe(gst::PadProbeType::BUFFER, move |_, info| { .add_probe(gst::PadProbeType::BUFFER, move |pad, info| {
if info.buffer().is_some_and(|b| b.size() > 0) if let Some(buffer) = info.buffer().filter(|buffer| buffer.size() > 0) {
&& !notice.swap(true, Ordering::Relaxed) *observed_frame
{ .lock()
.expect("last recording frame mutex poisoned") =
Some((buffer.to_owned(), pad.current_caps()));
if !notice.swap(true, Ordering::Relaxed) {
event("recording", None, None); event("recording", None, None);
} }
}
gst::PadProbeReturn::Ok gst::PadProbeReturn::Ok
}); });
@@ -548,10 +557,35 @@ fn encode(
finishing = Some(Instant::now()); finishing = Some(Instant::now());
// Inject after the capture source: it may already have disappeared // Inject after the capture source: it may already have disappeared
// when the portal closes on lock or monitor removal. // when the portal closes on lock or monitor removal.
let _ = queue let input = queue
.static_pad("sink") .static_pad("sink")
.ok_or("Encoder queue has no input")? .ok_or("Encoder queue has no input")?;
.send_event(gst::event::Eos::new()); {
// Serialize at the queue input, where an idle source holds
// no stream lock. The source pad itself can stay locked while
// waiting for its next frame, so locking it would stall stop.
let _input_lock = input.stream_lock();
let last = last_frame
.lock()
.expect("last recording frame mutex poisoned")
.take();
if let (Some((mut buffer, caps)), Some(now)) =
(last, pipeline.current_running_time())
&& caps == input.current_caps()
&& buffer.pts().is_some_and(|pts| {
now > pts + buffer.duration().unwrap_or(gst::ClockTime::ZERO)
})
{
// make_mut copies the buffer header while sharing its
// owned pixel memory; it does not copy a full video frame.
let tail = buffer.make_mut();
tail.set_pts(now);
tail.set_dts(None);
tail.set_duration(gst::ClockTime::from_nseconds(1));
let _ = input.chain(buffer);
}
let _ = input.send_event(gst::event::Eos::new());
}
for (source, queue) in &audio_inputs { for (source, queue) in &audio_inputs {
// Audio devices are still alive when video is revoked. // Audio devices are still alive when video is revoked.
// Let each source order EOS after its own startup events and // Let each source order EOS after its own startup events and
@@ -656,6 +690,131 @@ mod tests {
assert_video_decodes(&result.unwrap()); assert_video_decodes(&result.unwrap());
} }
#[test]
#[ignore = "requires GStreamer video plugins"]
fn encoder_preserves_the_duration_of_a_static_capture() {
gst::init().unwrap();
for (source_eos, with_audio) in [(false, false), (true, false), (false, true)] {
let directory = tempfile::tempdir().unwrap();
let source = gst::ElementFactory::make("appsrc")
.property("is-live", true)
.property("do-timestamp", true)
.property("format", gst::Format::Time)
.property(
"caps",
gst::Caps::builder("video/x-raw")
.field("format", "BGRx")
.field("width", 64i32)
.field("height", 48i32)
.field("framerate", gst::Fraction::new(30, 1))
.build(),
)
.build()
.unwrap();
let stop = Arc::new(AtomicBool::new(false));
let producer = source.clone();
let request_stop = stop.clone();
let worker = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(150));
let mut image = gst::Buffer::with_size(64 * 48 * 4).unwrap();
image
.get_mut()
.unwrap()
.map_writable()
.unwrap()
.as_mut_slice()
.fill(0x80);
assert_eq!(
producer.emit_by_name::<gst::FlowReturn>("push-buffer", &[&image]),
gst::FlowReturn::Ok
);
// The source stays alive without producing a duplicate image.
std::thread::sleep(Duration::from_millis(if with_audio { 4000 } else { 1200 }));
if source_eos {
assert_eq!(
producer.emit_by_name::<gst::FlowReturn>("end-of-stream", &[]),
gst::FlowReturn::Ok
);
} else {
request_stop.store(true, Ordering::Relaxed);
}
});
let audio = if with_audio {
vec![
gst::ElementFactory::make("audiotestsrc")
.property("is-live", true)
.build()
.unwrap(),
]
} else {
Vec::new()
};
let path = encode(source, audio, None, directory.path(), stop).unwrap();
worker.join().unwrap();
let audio_branch = if with_audio {
"demux.audio_0 ! queue ! opusdec ! audioconvert ! audioresample ! audio/x-raw,format=F32LE,rate=48000,channels=1 ! fakesink name=audio sync=false"
} else {
""
};
let decoder = gst::parse::launch(&format!("filesrc location={} ! matroskademux name=demux demux.video_0 ! queue ! vp8dec ! fakesink name=video sync=false {audio_branch}", path.display())).unwrap().downcast::<gst::Pipeline>().unwrap();
let audio_bytes = Arc::new(std::sync::atomic::AtomicU64::new(0));
if with_audio {
let bytes = audio_bytes.clone();
decoder
.by_name("audio")
.unwrap()
.static_pad("sink")
.unwrap()
.add_probe(gst::PadProbeType::BUFFER, move |_, info| {
if let Some(buffer) = info.buffer() {
bytes.fetch_add(buffer.size() as u64, Ordering::Relaxed);
}
gst::PadProbeReturn::Ok
});
}
let end = Arc::new(std::sync::atomic::AtomicU64::new(0));
let observed = end.clone();
decoder
.by_name("video")
.unwrap()
.static_pad("sink")
.unwrap()
.add_probe(gst::PadProbeType::BUFFER, move |_, info| {
if let Some(buffer) = info.buffer() {
let time = buffer.pts().unwrap_or(gst::ClockTime::ZERO)
+ buffer.duration().unwrap_or(gst::ClockTime::ZERO);
observed.fetch_max(time.nseconds(), Ordering::Relaxed);
}
gst::PadProbeReturn::Ok
});
decoder.set_state(gst::State::Playing).unwrap();
let result = decoder.bus().unwrap().timed_pop_filtered(
gst::ClockTime::from_seconds(5),
&[gst::MessageType::Eos, gst::MessageType::Error],
);
decoder.set_state(gst::State::Null).unwrap();
assert!(
matches!(
result.as_ref().map(|message| message.view()),
Some(gst::MessageView::Eos(_))
),
"static recording did not decode: {result:?}"
);
if with_audio {
assert!(
audio_bytes.load(Ordering::Relaxed) >= 3 * 48_000 * 4,
"static video interrupted audio: {} decoded bytes",
audio_bytes.load(Ordering::Relaxed)
);
}
assert!(
end.load(Ordering::Relaxed) >= 1_000_000_000,
"recording lost the idle interval: last decoded frame ends at {} ns",
end.load(Ordering::Relaxed)
);
}
}
#[test] #[test]
#[ignore = "requires GStreamer video plugins"] #[ignore = "requires GStreamer video plugins"]
fn encoder_finishes_a_real_webm() { fn encoder_finishes_a_real_webm() {
+552 -271
View File
@@ -1,7 +1,12 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::os::fd::AsRawFd;
use std::ptr::NonNull;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::time::Instant;
use std::time::{Duration, Instant};
use pipewire as pw; use pipewire as pw;
use pipewire::properties::properties; use pipewire::properties::properties;
@@ -9,7 +14,10 @@ use pipewire::spa;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use spa::pod::Pod; use spa::pod::Pod;
use crate::capture::{Capture, Frame}; mod capture;
mod rate;
use capture::{Capture, Completion};
use rate::FrameRate;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct Ready { pub struct Ready {
@@ -20,44 +28,155 @@ pub struct Ready {
pub height: u32, pub height: u32,
} }
struct Data { struct PoolBuffer {
latest: Arc<Mutex<Frame>>, generation: u64,
stop: Arc<AtomicBool>, buffer: capture::Buffer,
streaming: Arc<AtomicBool>,
fresh: Arc<AtomicBool>,
announced: bool,
sequence: u64,
negotiated: (u32, u32),
} }
// pipewire-rs exposes read-only metadata through Buffer. Own a dequeued raw #[derive(Clone, Copy)]
// buffer here so header writes retain exclusive access and every exit requeues it. struct HeldBuffer {
struct OutputBuffer<'a> { raw: NonNull<pw::sys::pw_buffer>,
stream: &'a pw::stream::Stream, generation: u64,
raw: std::ptr::NonNull<pw::sys::pw_buffer>,
} }
impl<'a> OutputBuffer<'a> {
fn dequeue(stream: &'a pw::stream::Stream) -> Option<Self> { struct Data {
// SAFETY: called on the owning PipeWire loop; the guard queues exactly once. capture: Rc<RefCell<Capture>>,
let raw = std::ptr::NonNull::new(unsafe { stream.dequeue_raw_buffer() })?; stop: Arc<AtomicBool>,
Some(Self { stream, raw }) rate: Arc<FrameRate>,
buffers: HashMap<usize, PoolBuffer>,
held: Option<HeldBuffer>,
generation: u64,
sequence: u64,
active: bool,
announced: bool,
negotiated: (u32, u32),
dma: bool,
force_shm: bool,
renegotiate: bool,
negotiating: bool,
next_capture: Instant,
} }
fn data(&mut self) -> Option<&mut spa::buffer::Data> {
// SAFETY: the dequeued buffer is exclusively ours until Drop. SPA Data impl Data {
// is repr(transparent), and PipeWire owns the mapped allocation. fn complete(&mut self, stream: &pw::stream::Stream, completion: Completion) {
let Some(held) = self.held.take() else {
return;
};
let Some(pool) = self
.buffers
.get(&(held.raw.as_ptr() as usize))
.filter(|pool| pool.generation == held.generation)
else {
// The completed image belongs to a retired pool. Refresh once more
// so the replacement pool receives its own initial image, then
// resume damage waiting. Never queue the retired raw pointer.
if let Err(error) = self.capture.borrow_mut().refresh() {
eprintln!("Could not refresh replacement capture buffers: {error}");
self.stop.store(true, Ordering::Relaxed);
}
return;
};
let mut deliver = matches!(completion, Completion::Ready)
&& self.active
&& !self.capture.borrow().stopped();
match completion {
Completion::Retry => {
self.renegotiate = self.negotiated != self.capture.borrow().constraints().size
}
Completion::Failed(error) => {
if self.dma && !self.capture.borrow().stopped() {
eprintln!("DMA-BUF capture unavailable ({error}); using shared memory");
self.force_shm = true;
self.renegotiate = true;
} else {
eprintln!("{error}");
self.stop.store(true, Ordering::Relaxed);
}
deliver = false;
}
Completion::Ready => (),
}
let raw = held.raw;
// SAFETY: this pointer is still registered with the same allocation
// generation and exclusively dequeued by us. All callbacks run on the
// owning PipeWire loop; remove_buffer invalidates the registry entry.
unsafe { unsafe {
let buffer = self.raw.as_ref().buffer.as_mut()?; let buffer = raw.as_ref().buffer.as_mut().expect("registered SPA buffer");
if buffer.n_datas == 0 || buffer.datas.is_null() { let data = &mut *buffer.datas;
return None; if let Some(chunk) = data.chunk.as_mut() {
chunk.offset = 0;
chunk.size = if deliver { pool.buffer.bytes } else { 0 };
chunk.stride = pool.buffer.stride as i32;
} }
buffer.datas.cast::<spa::buffer::Data>().as_mut() if deliver {
timestamp(raw, self.sequence);
self.sequence = self.sequence.wrapping_add(1);
}
stream.queue_raw_buffer(raw.as_ptr());
} }
} }
fn timestamp(&mut self, sequence: u64) { }
fn int_property(key: u32, value: i32) -> spa::pod::Property {
spa::pod::Property {
key,
flags: spa::pod::PropertyFlags::empty(),
value: spa::pod::Value::Int(value),
}
}
fn pod(object: spa::pod::Object) -> Vec<u8> {
spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&spa::pod::Value::Object(object),
)
.expect("SPA pod serialization into an owned Vec cannot fail for these object types")
.0
.into_inner()
}
fn video_format(width: u32, height: u32, rate: &FrameRate) -> Vec<u8> {
pod(spa::pod::object!(
spa::utils::SpaTypes::ObjectParamFormat,
spa::param::ParamType::EnumFormat,
spa::pod::property!(
spa::param::format::FormatProperties::MediaType,
Id,
spa::param::format::MediaType::Video
),
spa::pod::property!(
spa::param::format::FormatProperties::MediaSubtype,
Id,
spa::param::format::MediaSubtype::Raw
),
spa::pod::property!(
spa::param::format::FormatProperties::VideoFormat,
Id,
spa::param::video::VideoFormat::BGRx
),
spa::pod::property!(
spa::param::format::FormatProperties::VideoSize,
Rectangle,
spa::utils::Rectangle { width, height }
),
spa::pod::property!(
spa::param::format::FormatProperties::VideoFramerate,
Choice,
Range,
Fraction,
rate.fraction(),
spa::utils::Fraction { num: 1, denom: 1 },
spa::utils::Fraction {
num: rate.maximum,
denom: 1
}
)
))
}
fn timestamp(raw: std::ptr::NonNull<pw::sys::pw_buffer>, sequence: u64) {
// SAFETY: mutable access is exclusive, the metadata lookup checks its size, // SAFETY: mutable access is exclusive, the metadata lookup checks its size,
// and clock_gettime writes only to our initialized stack timespec. // and clock_gettime writes only to our initialized stack timespec.
unsafe { unsafe {
let buffer = self.raw.as_ref().buffer; let buffer = raw.as_ref().buffer;
if buffer.is_null() { if buffer.is_null() {
return; return;
} }
@@ -84,99 +203,92 @@ impl<'a> OutputBuffer<'a> {
} }
} }
} }
}
impl Drop for OutputBuffer<'_> {
fn drop(&mut self) {
// SAFETY: raw came from this stream and has not yet been queued.
unsafe {
self.stream.queue_raw_buffer(self.raw.as_ptr());
}
}
}
fn int_property(key: u32, value: i32) -> spa::pod::Property { fn buffer_params((width, height): (u32, u32), dma: bool) -> Vec<u8> {
spa::pod::Property {
key,
flags: spa::pod::PropertyFlags::empty(),
value: spa::pod::Value::Int(value),
}
}
fn pod(object: spa::pod::Object) -> Vec<u8> {
spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&spa::pod::Value::Object(object),
)
.expect("SPA pod serialization into an owned Vec cannot fail for these object types")
.0
.into_inner()
}
fn video_format(width: u32, height: u32) -> Vec<u8> {
pod(spa::pod::object!( pod(spa::pod::object!(
spa::utils::SpaTypes::ObjectParamFormat, spa::utils::SpaTypes::ObjectParamBuffers,
spa::param::ParamType::EnumFormat, spa::param::ParamType::Buffers,
spa::pod::property!( int_property(spa::sys::SPA_PARAM_BUFFERS_buffers, 4),
spa::param::format::FormatProperties::MediaType, int_property(spa::sys::SPA_PARAM_BUFFERS_blocks, 1),
Id, int_property(
spa::param::format::MediaType::Video spa::sys::SPA_PARAM_BUFFERS_size,
(width * height * 4) as i32
), ),
spa::pod::property!( int_property(spa::sys::SPA_PARAM_BUFFERS_stride, (width * 4) as i32),
spa::param::format::FormatProperties::MediaSubtype, int_property(spa::sys::SPA_PARAM_BUFFERS_align, 16),
Id, // This is a set of supported memory types, not an exact integer.
spa::param::format::MediaSubtype::Raw // Consumers such as OBS offer a scalar mask with several bits;
), // a flags choice lets SPA intersect it with our one supported type.
spa::pod::property!( spa::pod::Property {
spa::param::format::FormatProperties::VideoFormat, key: spa::sys::SPA_PARAM_BUFFERS_dataType,
Id, flags: spa::pod::PropertyFlags::empty(),
spa::param::video::VideoFormat::BGRx value: spa::pod::Value::Choice(spa::pod::ChoiceValue::Int(spa::utils::Choice(
), spa::utils::ChoiceFlags::empty(),
spa::pod::property!( spa::utils::ChoiceEnum::Flags {
spa::param::format::FormatProperties::VideoSize, default: 1
Rectangle, << if dma {
spa::utils::Rectangle { width, height } spa::sys::SPA_DATA_DmaBuf
), } else {
spa::pod::property!( spa::sys::SPA_DATA_MemFd
spa::param::format::FormatProperties::VideoFramerate, },
Fraction, flags: Vec::new(),
spa::utils::Fraction { num: 30, denom: 1 } },
) ),)),
}
)) ))
} }
fn formats(size: (u32, u32), rate: &FrameRate, dma: bool) -> Vec<Vec<u8>> {
let shm = video_format(size.0, size.1, rate);
if !dma {
return vec![shm];
}
let (_, mut format) = spa::pod::deserialize::PodDeserializer::deserialize_any_from(&shm)
.expect("serialized video format");
if let spa::pod::Value::Object(ref mut object) = format {
object.properties.push(spa::pod::Property {
key: spa::sys::SPA_FORMAT_VIDEO_modifier,
flags: spa::pod::PropertyFlags::MANDATORY,
value: spa::pod::Value::Long(0),
});
}
let dma =
spa::pod::serialize::PodSerializer::serialize(std::io::Cursor::new(Vec::new()), &format)
.expect("serializable video format")
.0
.into_inner();
vec![dma, shm]
}
pub fn run( pub fn run(
name: String, name: String,
cursor: bool, cursor: bool,
generation: Option<u32>, generation: Option<u32>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let configured_rate = std::env::var("FERESE_CAPTURE_MAX_FPS").ok();
let rate = Arc::new(FrameRate::new(configured_rate.as_deref())?);
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let (cancel_read, mut cancel_write) = std::os::unix::net::UnixStream::pair()?;
cancel_read.set_nonblocking(true)?;
let pipe_stop = stop.clone(); let pipe_stop = stop.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let mut byte = [0u8]; let mut byte = [0u8];
let _ = std::io::stdin().read(&mut byte); let _ = std::io::stdin().read(&mut byte);
pipe_stop.store(true, Ordering::Relaxed); pipe_stop.store(true, Ordering::Relaxed);
let _ = cancel_write.write_all(&[1]);
}); });
let mut capture = Capture::connect(&stop)?; let capture = Rc::new(RefCell::new(Capture::connect(
if let Some(generation) = generation { &name, cursor, generation, &stop,
capture.pin_output(&name, generation)?; )?));
} let size = capture.borrow().constraints().size;
let deadline = Instant::now() + Duration::from_secs(5); let logical_size = capture.borrow().logical_size();
let first = loop { let force_shm = std::env::var_os("FERESE_CAPTURE_FORCE_SHM").is_some_and(|value| value == "1");
match capture.frame(&name, cursor, &stop, Vec::new()) { let attempted_dma = !force_shm && capture.borrow().dma_supported();
Err(error) if error == crate::capture::BUSY && Instant::now() < deadline => { // Verify GBM allocation before advertising DMA-BUF to the consumer. Failure
std::thread::sleep(Duration::from_millis(10)); // here leaves the same capture session usable through its SHM constraints.
} let dma = attempted_dma && capture.borrow_mut().allocate(true).is_ok();
result => break result?, let force_shm = force_shm || (attempted_dma && !dma);
}
};
let (width, height) = (first.width, first.height);
let logical_size = first.logical_size;
let resizable = name.starts_with("window:");
let latest = Arc::new(Mutex::new(first));
let streaming = Arc::new(AtomicBool::new(false));
let fresh = Arc::new(AtomicBool::new(false));
pw::init(); pw::init();
let mainloop = pw::main_loop::MainLoopRc::new(None)?; let mainloop = pw::main_loop::MainLoopRc::new(None)?;
let context = pw::context::ContextRc::new(&mainloop, None)?; let context = pw::context::ContextRc::new(&mainloop, None)?;
let core = context.connect_rc(None)?; let core = context.connect_rc(None)?;
@@ -184,37 +296,46 @@ pub fn run(
&core, &core,
"Ferese ScreenCast", "Ferese ScreenCast",
properties! { properties! {
"media.class" => "Video/Source", "media.class" => "Video/Source", "media.type" => "Video", "media.category" => "Capture",
"media.type" => "Video", "media.role" => "Screen", "node.name" => format!("ferese.screencast.{}", std::process::id()),
"media.category" => "Capture", "node.description" => format!("Ferese — {name}"), "node.virtual" => "true",
"media.role" => "Screen", "node.want-driver" => "true", "node.always-process" => "false", "node.pause-on-idle" => "true",
"node.name" => format!("ferese.screencast.{}", std::process::id()), "node.latency" => format!("{}/{}", rate.fraction().denom, rate.fraction().num),
"node.description" => format!("Ferese — {name}"),
"node.virtual" => "true",
"node.want-driver" => "true",
"node.always-process" => "false",
"node.pause-on-idle" => "true",
"node.latency" => "1/30",
}, },
)?; )?;
let format_frames = latest.clone(); let data = Rc::new(RefCell::new(Data {
let data = Data { capture: capture.clone(),
latest: latest.clone(),
stop: stop.clone(), stop: stop.clone(),
streaming: streaming.clone(), rate: rate.clone(),
fresh: fresh.clone(), buffers: HashMap::new(),
announced: false, held: None,
generation: 0,
sequence: 0, sequence: 0,
negotiated: (width, height), active: false,
}; announced: false,
negotiated: size,
dma,
force_shm,
renegotiate: false,
negotiating: false,
next_capture: Instant::now(),
}));
let _listener = stream let _listener = stream
.add_local_listener_with_user_data(data) .add_local_listener_with_user_data(data.clone())
.state_changed(move |stream, data, _, state| { .state_changed(move |stream, data, _, state| {
data.fresh.store(false, Ordering::SeqCst); let mut data = data.borrow_mut();
data.streaming.store( let active = matches!(state, pw::stream::StreamState::Streaming);
matches!(state, pw::stream::StreamState::Streaming), if active
Ordering::Relaxed, && !data.active
); && let Err(error) = data.capture.borrow_mut().refresh()
{
eprintln!("Could not resume capture: {error}");
data.stop.store(true, Ordering::Relaxed);
}
if active && !data.active {
data.next_capture = Instant::now();
}
data.active = active;
match state { match state {
pw::stream::StreamState::Paused | pw::stream::StreamState::Streaming pw::stream::StreamState::Paused | pw::stream::StreamState::Streaming
if !data.announced => if !data.announced =>
@@ -227,16 +348,16 @@ pub fn run(
serde_json::to_string(&Ready { serde_json::to_string(&Ready {
logical_size, logical_size,
node, node,
width, width: size.0,
height height: size.1
}) })
.expect("typed configuration and IPC values must serialize to JSON") .expect("serializable capture readiness")
); );
let _ = std::io::stdout().flush(); let _ = std::io::stdout().flush();
} }
} }
pw::stream::StreamState::Unconnected if data.announced => { pw::stream::StreamState::Unconnected if data.announced => {
data.stop.store(true, Ordering::Relaxed); data.stop.store(true, Ordering::Relaxed)
} }
pw::stream::StreamState::Error(error) => { pw::stream::StreamState::Error(error) => {
eprintln!("PipeWire stream: {error}"); eprintln!("PipeWire stream: {error}");
@@ -245,44 +366,51 @@ pub fn run(
_ => (), _ => (),
} }
}) })
.param_changed(move |stream, data, id, param| { .param_changed(|stream, shared, id, param| {
if id != spa::param::ParamType::Format.as_raw() { if id != spa::param::ParamType::Format.as_raw() {
return; return;
} }
let Some(param) = param else { return }; let Some(param) = param else {
return;
};
let mut format = spa::param::video::VideoInfoRaw::default(); let mut format = spa::param::video::VideoInfoRaw::default();
let mut data = shared.borrow_mut();
if format.parse(param).is_err() if format.parse(param).is_err()
|| format.size().width == 0
|| format.size().height == 0
|| (!resizable && (format.size().width, format.size().height) != (width, height))
|| u64::from(format.size().width) * u64::from(format.size().height) * 4
> 128 * 1024 * 1024
|| format.format() != spa::param::video::VideoFormat::BGRx || format.format() != spa::param::video::VideoFormat::BGRx
|| (format.size().width, format.size().height)
!= data.capture.borrow().constraints().size
|| data.rate.negotiate(format.framerate()).is_err()
{ {
data.stop.store(true, Ordering::Relaxed); data.stop.store(true, Ordering::Relaxed);
return; return;
} }
let (width, height) = (format.size().width, format.size().height); data.negotiated = (format.size().width, format.size().height);
data.negotiated = (width, height); data.dma = format
let buffers = pod(spa::pod::object!( .flags()
spa::utils::SpaTypes::ObjectParamBuffers, .contains(spa::param::video::VideoFlags::MODIFIER);
spa::param::ParamType::Buffers, if data.dma && (data.force_shm || format.modifier() != 0) {
int_property(spa::sys::SPA_PARAM_BUFFERS_buffers, 4), data.stop.store(true, Ordering::Relaxed);
int_property(spa::sys::SPA_PARAM_BUFFERS_blocks, 1), return;
int_property( }
spa::sys::SPA_PARAM_BUFFERS_size, data.negotiating = false;
(width * height * 4) as i32 let selected = data.rate.fraction();
), let properties =
int_property(spa::sys::SPA_PARAM_BUFFERS_stride, (width * 4) as i32), properties! { "node.latency" => format!("{}/{}", selected.denom, selected.num) };
int_property(spa::sys::SPA_PARAM_BUFFERS_align, 16) // SAFETY: this loop owns the stream; PipeWire copies the live dictionary.
)); unsafe {
pw::sys::pw_stream_update_properties(
stream.as_raw_ptr(),
properties.dict().as_raw_ptr(),
);
}
let buffers = buffer_params(data.negotiated, data.dma);
let header = pod(spa::pod::object!( let header = pod(spa::pod::object!(
spa::utils::SpaTypes::ObjectParamMeta, spa::utils::SpaTypes::ObjectParamMeta,
spa::param::ParamType::Meta, spa::param::ParamType::Meta,
spa::pod::Property { spa::pod::Property {
key: spa::sys::SPA_PARAM_META_type, key: spa::sys::SPA_PARAM_META_type,
flags: spa::pod::PropertyFlags::empty(), flags: spa::pod::PropertyFlags::empty(),
value: spa::pod::Value::Id(spa::utils::Id(spa::sys::SPA_META_Header)), value: spa::pod::Value::Id(spa::utils::Id(spa::sys::SPA_META_Header))
}, },
int_property( int_property(
spa::sys::SPA_PARAM_META_size, spa::sys::SPA_PARAM_META_size,
@@ -291,145 +419,298 @@ pub fn run(
)); ));
if stream if stream
.update_params(&mut [ .update_params(&mut [
Pod::from_bytes(&buffers) Pod::from_bytes(&buffers).expect("serialized buffer constraints"),
.expect("SPA pod bytes were produced by the matching pod serializer"), Pod::from_bytes(&header).expect("serialized metadata constraints"),
Pod::from_bytes(&header)
.expect("SPA pod bytes were produced by the matching pod serializer"),
]) ])
.is_err() .is_err()
{ {
data.stop.store(true, Ordering::Relaxed); data.stop.store(true, Ordering::Relaxed);
} }
}) })
.process(|stream, data| { .add_buffer(|_, shared, raw| {
let Some(mut buffer) = OutputBuffer::dequeue(stream) else { let mut data = shared.borrow_mut();
return; if raw.is_null() || data.buffers.len() >= 8 {
};
let Some(target) = buffer.data() else {
return;
};
if data.stop.load(Ordering::Relaxed) || !data.fresh.load(Ordering::SeqCst) {
*target.chunk_mut().size_mut() = 0;
return;
}
let frame = data.latest.lock().expect(
"capture frame state must remain consistent across producer and PipeWire callbacks",
);
if (frame.width, frame.height) != data.negotiated {
*target.chunk_mut().size_mut() = 0;
return;
}
let row = frame.width as usize * 4;
let bytes = row * frame.height as usize;
let Some(destination) = target.data() else {
return;
};
if destination.len() < bytes {
data.stop.store(true, Ordering::Relaxed); data.stop.store(true, Ordering::Relaxed);
return; return;
} }
for (dst, src) in destination[..bytes] let result = data.capture.borrow_mut().allocate(data.dma);
.chunks_exact_mut(row) match result {
.zip(frame.pixels.chunks_exact(frame.stride as usize)) Ok(buffer) => {
// SAFETY: PipeWire grants mutable access during add_buffer.
// ALLOC_BUFFERS delegates fd ownership to us until remove_buffer.
unsafe {
let Some(spa) = (*raw).buffer.as_mut() else {
data.stop.store(true, Ordering::Relaxed);
return;
};
if spa.n_datas != 1 || spa.datas.is_null() {
data.stop.store(true, Ordering::Relaxed);
return;
}
let target = &mut *spa.datas;
target.type_ = if data.dma {
spa::sys::SPA_DATA_DmaBuf
} else {
spa::sys::SPA_DATA_MemFd
};
target.flags = spa::sys::SPA_DATA_FLAG_READWRITE;
target.fd = i64::from(buffer.fd.as_raw_fd());
target.mapoffset = 0;
target.maxsize = buffer.bytes;
target.data = std::ptr::null_mut();
if let Some(chunk) = target.chunk.as_mut() {
chunk.offset = 0;
chunk.size = 0;
chunk.stride = buffer.stride as i32;
}
}
data.generation = data.generation.wrapping_add(1);
let generation = data.generation;
data.buffers
.insert(raw as usize, PoolBuffer { generation, buffer });
}
Err(error) if data.dma => {
eprintln!("DMA-BUF allocation failed ({error}); using shared memory");
data.force_shm = true;
data.renegotiate = true;
}
Err(error) => {
eprintln!("Capture allocation failed: {error}");
data.stop.store(true, Ordering::Relaxed);
}
}
})
.remove_buffer(|_, shared, raw| {
shared.borrow_mut().buffers.remove(&(raw as usize));
})
.process(|stream, shared| {
let mut data = shared.borrow_mut();
if !data.active
|| data.held.is_some()
|| data.negotiating
|| Instant::now() < data.next_capture
|| data.stop.load(Ordering::Relaxed)
{ {
dst.copy_from_slice(&src[..row]); return;
}
// SAFETY: called only on the owning loop. The registry and held token
// retain exclusive ownership until completion, removal or disconnect.
let Some(raw) = NonNull::new(unsafe { stream.dequeue_raw_buffer() }) else {
return;
};
let key = raw.as_ptr() as usize;
let result = data
.buffers
.get(&key)
.filter(|pool| pool.buffer.size == data.capture.borrow().constraints().size)
.map(|pool| {
(
pool.generation,
data.capture.borrow_mut().submit(&pool.buffer),
)
});
if let Some((generation, Ok(()))) = result {
data.held = Some(HeldBuffer { raw, generation });
data.next_capture = Instant::now() + data.rate.interval();
} else {
// SAFETY: this buffer was just dequeued and hasn't been handed to
// Wayland. Return it empty; old dimensions cannot reach consumers.
unsafe {
if let Some(chunk) = (*(*raw.as_ptr()).buffer)
.datas
.as_mut()
.and_then(|data| data.chunk.as_mut())
{
chunk.size = 0;
}
stream.queue_raw_buffer(raw.as_ptr());
}
} }
*target.chunk_mut().offset_mut() = 0;
*target.chunk_mut().size_mut() = bytes as u32;
*target.chunk_mut().stride_mut() = row as i32;
buffer.timestamp(data.sequence);
data.sequence = data.sequence.wrapping_add(1);
}) })
.register()?; .register()?;
let capture_io = capture.clone();
let format = video_format(width, height); let _capture_io = mainloop.loop_().add_io(
capture.borrow().poll_fd()?,
spa::support::system::IoFlags::IN,
move |_| capture_io.borrow_mut().dispatch(),
);
let _cancel_io =
mainloop
.loop_()
.add_io(cancel_read, spa::support::system::IoFlags::IN, |_| {});
let advertised = formats(size, &rate, dma);
let mut parameters: Vec<_> = advertised
.iter()
.map(|format| Pod::from_bytes(format).expect("serialized video format"))
.collect();
// RT_PROCESS stays disabled: PipeWire callbacks, Wayland dispatch and the
// buffer registry all belong to this single main loop.
stream.connect( stream.connect(
spa::utils::Direction::Output, spa::utils::Direction::Output,
None, None,
pw::stream::StreamFlags::MAP_BUFFERS | pw::stream::StreamFlags::DRIVER, pw::stream::StreamFlags::ALLOC_BUFFERS | pw::stream::StreamFlags::DRIVER,
&mut [Pod::from_bytes(&format) &mut parameters,
.expect("SPA pod bytes were produced by the matching pod serializer")],
)?; )?;
let interval = Duration::from_nanos(1_000_000_000 / 30);
let capture_stop = stop.clone();
let capture_streaming = streaming.clone();
let capture_thread = std::thread::spawn(move || {
let mut spare = Vec::new();
let mut checked = Instant::now() - Duration::from_secs(1);
while !capture_stop.load(Ordering::Relaxed) {
let start = Instant::now();
if capture_streaming.load(Ordering::Relaxed)
|| checked.elapsed() >= Duration::from_secs(1)
{
checked = Instant::now();
match capture.frame(&name, cursor, &capture_stop, std::mem::take(&mut spare)) {
Ok(frame) if resizable || (frame.width == width && frame.height == height) => {
spare = std::mem::replace(&mut *latest.lock().expect("capture frame state must remain consistent across producer and PipeWire callbacks"), frame).pixels;
fresh.store(capture_streaming.load(Ordering::Relaxed), Ordering::SeqCst);
}
Ok(_) => {
eprintln!("Shared monitor changed size; start sharing again");
capture_stop.store(true, Ordering::Relaxed);
}
Err(error) if error == crate::capture::BUSY => (),
Err(error) => {
eprintln!("{error}");
capture_stop.store(true, Ordering::Relaxed);
}
}
}
std::thread::sleep(interval.saturating_sub(start.elapsed()));
}
});
let mut next_frame = Instant::now(); let mut next_frame = Instant::now();
let mut requested_size = (width, height); while !stop.load(Ordering::Relaxed) && !capture.borrow().stopped() {
let mut renegotiation_error = None; capture.borrow_mut().dispatch();
let completion = capture.borrow_mut().completion();
while !stop.load(Ordering::Relaxed) { if let Some(completion) = completion {
let size = { data.borrow_mut().complete(&stream, completion);
let frame = format_frames.lock().expect( }
"capture frame state must remain consistent across producer and PipeWire callbacks", let changed = capture.borrow_mut().take_changed();
); if changed && !name.starts_with("window:") && capture.borrow().constraints().size != size {
(frame.width, frame.height) eprintln!("Shared monitor changed size; start sharing again");
};
if resizable && size != requested_size {
let format = video_format(size.0, size.1);
if let Err(error) = stream.update_params(&mut [Pod::from_bytes(&format)
.expect("SPA pod bytes were produced by the matching pod serializer")])
{
renegotiation_error = Some(error);
stop.store(true, Ordering::Relaxed);
break; break;
} }
requested_size = size; let renegotiate = {
} let mut data = data.borrow_mut();
let active = streaming.load(Ordering::Relaxed); std::mem::take(&mut data.renegotiate) || changed
let timeout = if active {
next_frame
.saturating_duration_since(Instant::now())
.min(Duration::from_millis(10))
} else {
Duration::from_millis(10)
}; };
mainloop if renegotiate {
.loop_() data.borrow_mut().negotiating = true;
.iterate(pw::loop_::Timeout::Finite(timeout)); let advertised = formats(
if streaming.load(Ordering::Relaxed) && Instant::now() >= next_frame { capture.borrow().constraints().size,
let _ = stream.trigger_process(); &rate,
next_frame = Instant::now() + interval; !data.borrow().force_shm && capture.borrow().dma_supported(),
} else if !streaming.load(Ordering::Relaxed) { );
next_frame = Instant::now(); let mut params: Vec<_> = advertised
.iter()
.map(|format| Pod::from_bytes(format).expect("serialized video format"))
.collect();
stream.update_params(&mut params)?;
} }
let can_capture = {
let data = data.borrow();
data.active && data.held.is_none() && !data.renegotiate && !data.negotiating
};
let deadline = next_frame.max(data.borrow().next_capture);
if can_capture && Instant::now() >= deadline {
stream.trigger_process()?;
next_frame = Instant::now() + rate.interval();
}
let timeout = if can_capture {
pw::loop_::Timeout::Finite(
next_frame
.max(data.borrow().next_capture)
.saturating_duration_since(Instant::now()),
)
} else {
pw::loop_::Timeout::Infinite
};
mainloop.loop_().iterate(timeout);
} }
let _ = stream.disconnect(); let _ = stream.disconnect();
let _ = capture_thread.join();
if let Some(error) = renegotiation_error {
return Err(error.into());
}
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn buffer_types_intersect_obs_masks_in_either_negotiation_order() {
// PipeWire expands OBS's MemPtr bit to also permit MemFd transport.
let obs_mask = (1 << spa::sys::SPA_DATA_MemPtr)
| (1 << spa::sys::SPA_DATA_MemFd)
| (1 << spa::sys::SPA_DATA_DmaBuf);
let consumer = pod(spa::pod::object!(
spa::utils::SpaTypes::ObjectParamBuffers,
spa::param::ParamType::Buffers,
int_property(spa::sys::SPA_PARAM_BUFFERS_dataType, obs_mask)
));
for dma in [false, true] {
let producer = buffer_params((735, 840), dma);
for reverse in [false, true] {
let (offer, filter) = if reverse {
(&consumer, &producer)
} else {
(&producer, &consumer)
};
let mut bytes = vec![0; 1024];
let builder = spa::pod::builder::Builder::new(&mut bytes);
let mut result = std::ptr::null_mut();
// SAFETY: both input pods and the builder's writable storage
// remain alive throughout filtering and reading the result.
let status = unsafe {
spa::sys::spa_pod_filter(
builder.as_raw_ptr(),
&mut result,
Pod::from_bytes(offer).unwrap().as_raw_ptr(),
Pod::from_bytes(filter).unwrap().as_raw_ptr(),
)
};
assert!(
status >= 0,
"SPA rejected dma={dma} reverse={reverse}: {status}"
);
assert!(!result.is_null());
// SAFETY: successful filtering initialized this pod in the
// still-live builder storage; no subsequent writes occur.
let filtered = unsafe { Pod::from_raw(result) };
let (_, value) = spa::pod::deserialize::PodDeserializer::deserialize_any_from(
filtered.as_bytes(),
)
.unwrap();
let spa::pod::Value::Object(object) = value else {
panic!("buffer parameters must remain an object")
};
let types = object
.properties
.iter()
.find(|property| property.key == spa::sys::SPA_PARAM_BUFFERS_dataType)
.unwrap();
let expected = 1
<< if dma {
spa::sys::SPA_DATA_DmaBuf
} else {
spa::sys::SPA_DATA_MemFd
};
match &types.value {
spa::pod::Value::Int(mask) => assert_eq!(*mask, expected),
spa::pod::Value::Choice(spa::pod::ChoiceValue::Int(spa::utils::Choice(
_,
choice,
))) => match choice {
spa::utils::ChoiceEnum::Flags { default, .. }
| spa::utils::ChoiceEnum::None(default) => assert_eq!(*default, expected),
other => panic!("unexpected filtered choice: {other:?}"),
},
other => panic!("unexpected filtered mask: {other:?}"),
}
}
}
}
#[test]
fn dma_is_an_explicit_format_with_a_shared_memory_alternative() {
let rate = FrameRate::new(None).unwrap();
let offers = formats((640, 480), &rate, true);
assert_eq!(offers.len(), 2);
for (index, offer) in offers.iter().enumerate() {
let (_, value) =
spa::pod::deserialize::PodDeserializer::deserialize_any_from(offer).unwrap();
let spa::pod::Value::Object(object) = value else {
panic!("video format must be an object");
};
let modifier = object
.properties
.iter()
.find(|property| property.key == spa::sys::SPA_FORMAT_VIDEO_modifier);
if index == 0 {
let modifier = modifier.expect("DMA-BUF requires an explicit modifier");
assert!(modifier.flags.contains(spa::pod::PropertyFlags::MANDATORY));
assert_eq!(modifier.value, spa::pod::Value::Long(0));
} else {
assert!(
modifier.is_none(),
"SHM must not inherit the DMA-BUF modifier"
);
}
assert!(object.properties.iter().any(|property| property.key
== spa::sys::SPA_FORMAT_VIDEO_framerate
&& matches!(property.value, spa::pod::Value::Choice(_))));
}
assert_eq!(formats((640, 480), &rate, false), vec![offers[1].clone()]);
}
}
+627
View File
@@ -0,0 +1,627 @@
use std::collections::HashMap;
use std::fs::File;
use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd};
use std::os::unix::fs::MetadataExt;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use ferese_protocols::window_capture::v1::client::ferese_window_capture_manager_v1 as window_manager;
use wayland_client::protocol::{wl_buffer, wl_output, wl_registry, wl_shm, wl_shm_pool};
use wayland_client::{Connection, Dispatch, EventQueue, QueueHandle, WEnum};
use wayland_protocols::ext::image_capture_source::v1::client::{
ext_image_capture_source_v1 as source,
ext_output_image_capture_source_manager_v1 as output_manager,
};
use wayland_protocols::ext::image_copy_capture::v1::client::{
ext_image_copy_capture_frame_v1 as frame, ext_image_copy_capture_manager_v1 as manager,
ext_image_copy_capture_session_v1 as session,
};
use wayland_protocols::wp::linux_dmabuf::zv1::client::{
zwp_linux_buffer_params_v1 as params, zwp_linux_dmabuf_v1 as dmabuf,
};
#[derive(Clone, Debug, Default, PartialEq)]
pub(super) struct Constraints {
pub size: (u32, u32),
pub shm: bool,
pub device: Option<u64>,
pub linear: bool,
}
impl Constraints {
pub(super) fn validate(&self) -> Result<(), String> {
let (width, height) = self.size;
if width == 0
|| height == 0
|| u64::from(width)
.checked_mul(u64::from(height))
.and_then(|pixels| pixels.checked_mul(4))
.is_none_or(|bytes| bytes > 128 * 1024 * 1024)
|| !self.shm
{
return Err("Invalid capture buffer constraints".into());
}
Ok(())
}
}
#[derive(Debug)]
pub(super) enum Completion {
Ready,
Retry,
Failed(String),
}
pub(super) struct Buffer {
pub proxy: wl_buffer::WlBuffer,
pub fd: OwnedFd,
pub stride: u32,
pub bytes: u32,
pub size: (u32, u32),
_bo: Option<gbm::BufferObject<()>>,
}
impl Drop for Buffer {
fn drop(&mut self) {
self.proxy.destroy();
}
}
#[derive(Default)]
struct State {
outputs: HashMap<u32, (wl_output::WlOutput, String)>,
manager: Option<manager::ExtImageCopyCaptureManagerV1>,
windows: Option<window_manager::FereseWindowCaptureManagerV1>,
output_manager: Option<output_manager::ExtOutputImageCaptureSourceManagerV1>,
shm: Option<wl_shm::WlShm>,
dma: Option<dmabuf::ZwpLinuxDmabufV1>,
pending_constraints: Constraints,
constraints: Option<Constraints>,
logical_size: Option<(u32, u32)>,
changed: bool,
stopped: bool,
completion: Option<Completion>,
error: Option<String>,
selected: Option<u32>,
}
pub(super) struct Capture {
connection: Connection,
queue: EventQueue<State>,
state: State,
session: session::ExtImageCopyCaptureSessionV1,
source: source::ExtImageCaptureSourceV1,
frame: Option<frame::ExtImageCopyCaptureFrameV1>,
pub device: Option<gbm::Device<File>>,
device_id: Option<u64>,
}
impl Capture {
pub(super) fn connect(
name: &str,
cursor: bool,
generation: Option<u32>,
stop: &AtomicBool,
) -> Result<Self, String> {
let connection = Connection::connect_to_env().map_err(|error| error.to_string())?;
let mut queue = connection.new_event_queue();
let qh = queue.handle();
let mut state = State::default();
connection.display().get_registry(&qh, ());
// Startup alone uses bounded roundtrips. Streaming dispatch is integrated
// with PipeWire's fd loop and never waits for an unchanged source.
let deadline = Instant::now() + Duration::from_secs(5);
for _ in 0..2 {
let done = std::sync::Arc::new(AtomicBool::new(false));
connection.display().sync(&qh, done.clone());
while !done.load(Ordering::Relaxed) {
if stop.load(Ordering::Relaxed) || Instant::now() >= deadline {
return Err("Capture startup cancelled or timed out".into());
}
dispatch(&connection, &mut queue, &mut state, 20)?;
}
}
let source = if let Some(id) = name
.strip_prefix("window:")
.and_then(|value| value.parse::<u64>().ok())
{
state
.windows
.as_ref()
.ok_or("Persistent window capture is unavailable")?
.create_source((id >> 32) as u32, id as u32, &qh, ())
} else {
let (&id, (output, _)) = state
.outputs
.iter()
.find(|(_, (_, output_name))| output_name == name)
.ok_or("Shared display disconnected")?;
if generation.is_some_and(|generation| generation != id) {
return Err("Shared display was replaced".into());
}
state.selected = Some(id);
state
.output_manager
.as_ref()
.ok_or("Persistent display capture is unavailable")?
.create_source(output, &qh, ())
};
let options = if cursor {
manager::Options::PaintCursors
} else {
manager::Options::empty()
};
let session = state
.manager
.as_ref()
.ok_or("Persistent screen capture is unavailable")?
.create_session(&source, options, &qh, ());
while state.constraints.is_none() && !state.stopped {
if stop.load(Ordering::Relaxed) || Instant::now() >= deadline {
return Err("Capture startup cancelled or timed out".into());
}
dispatch(&connection, &mut queue, &mut state, 20)?;
}
if state.stopped {
return Err("Capture source is unavailable".into());
}
let constraints = state
.constraints
.as_ref()
.expect("capture constraints received");
constraints.validate()?;
let device_id = constraints.device;
let device = constraints
.device
.and_then(open_device)
.and_then(|file| gbm::Device::new(file).ok());
Ok(Self {
connection,
queue,
state,
session,
source,
frame: None,
device,
device_id,
})
}
pub(super) fn poll_fd(&self) -> Result<OwnedFd, String> {
self.connection
.backend()
.poll_fd()
.try_clone_to_owned()
.map_err(|error| error.to_string())
}
pub(super) fn constraints(&self) -> &Constraints {
self.state
.constraints
.as_ref()
.expect("capture constraints received")
}
pub(super) fn logical_size(&self) -> Option<(u32, u32)> {
self.state.logical_size
}
pub(super) fn stopped(&self) -> bool {
self.state.stopped || self.state.error.is_some()
}
pub(super) fn take_changed(&mut self) -> bool {
std::mem::take(&mut self.state.changed)
}
pub(super) fn dma_supported(&self) -> bool {
self.constraints().linear && self.device.is_some() && self.state.dma.is_some()
}
pub(super) fn dispatch(&mut self) {
if let Err(error) = dispatch(&self.connection, &mut self.queue, &mut self.state, 0) {
self.state.error = Some(error);
}
let device = self.constraints().device;
if device != self.device_id {
self.device = device
.and_then(open_device)
.and_then(|file| gbm::Device::new(file).ok());
self.device_id = device;
}
}
pub(super) fn completion(&mut self) -> Option<Completion> {
let completion = self.state.completion.take()?;
if let Some(frame) = self.frame.take() {
frame.destroy();
}
Some(completion)
}
pub(super) fn refresh(&mut self) -> Result<(), String> {
self.state
.windows
.as_ref()
.ok_or("Capture refresh is unavailable")?
.refresh_source(&self.source);
self.connection.flush().map_err(|error| error.to_string())
}
pub(super) fn submit(&mut self, buffer: &Buffer) -> Result<(), String> {
if self.frame.is_some() || self.stopped() {
return Err("Capture is already pending or stopped".into());
}
let frame = self.session.create_frame(&self.queue.handle(), ());
frame.attach_buffer(&buffer.proxy);
// PipeWire may return any pool buffer. Mark it fully stale; only source
// damage wakes the session, then the compositor fills this whole buffer.
frame.damage_buffer(0, 0, buffer.size.0 as i32, buffer.size.1 as i32);
frame.capture();
self.frame = Some(frame);
self.connection.flush().map_err(|error| error.to_string())
}
pub(super) fn allocate(&mut self, dma: bool) -> Result<Buffer, String> {
let (width, height) = self.constraints().size;
let qh = self.queue.handle();
if dma {
let device = self.device.as_ref().ok_or("No DMA-BUF allocation device")?;
let bo = device
.create_buffer_object::<()>(
width,
height,
gbm::Format::Xrgb8888,
gbm::BufferObjectFlags::RENDERING | gbm::BufferObjectFlags::LINEAR,
)
.map_err(|error| error.to_string())?;
if bo.plane_count() != 1 || bo.modifier() != gbm::Modifier::Linear {
return Err("Allocator did not supply a linear single-plane buffer".into());
}
let fd = bo.fd().map_err(|error| error.to_string())?;
let stride = bo.stride_for_plane(0);
let offset = bo.offset(0);
if offset != 0 {
return Err("Unsupported DMA-BUF plane offset".into());
}
let bytes = stride
.checked_mul(height)
.ok_or("DMA-BUF allocation is too large")?;
let params = self
.state
.dma
.as_ref()
.ok_or("Missing DMA-BUF global")?
.create_params(&qh, ());
params.add(fd.as_fd(), 0, offset, stride, 0, 0);
let proxy = params.create_immed(
width as i32,
height as i32,
gbm::Format::Xrgb8888 as u32,
params::Flags::empty(),
&qh,
(),
);
params.destroy();
return Ok(Buffer {
proxy,
fd,
stride,
bytes,
size: (width, height),
_bo: Some(bo),
});
}
let stride = width.checked_mul(4).ok_or("SHM row is too large")?;
let bytes = stride
.checked_mul(height)
.filter(|bytes| *bytes <= 128 * 1024 * 1024)
.ok_or("SHM buffer is too large")?;
// SAFETY: this returns a new owned anonymous file with a fixed C name.
let fd = unsafe {
libc::memfd_create(
c"ferese-stream".as_ptr(),
libc::MFD_CLOEXEC | libc::MFD_ALLOW_SEALING,
)
};
if fd < 0 {
return Err(std::io::Error::last_os_error().to_string());
}
// SAFETY: memfd_create succeeded and ownership transfers to File.
let file = unsafe { File::from_raw_fd(fd) };
file.set_len(u64::from(bytes))
.map_err(|error| error.to_string())?;
// SAFETY: this file is owned here and has its final size. The compositor
// can map it but neither process needs to resize a pool buffer.
if unsafe {
libc::fcntl(
file.as_raw_fd(),
libc::F_ADD_SEALS,
libc::F_SEAL_SHRINK | libc::F_SEAL_GROW | libc::F_SEAL_SEAL,
)
} < 0
{
return Err(std::io::Error::last_os_error().to_string());
}
let pool = self
.state
.shm
.as_ref()
.ok_or("Missing SHM global")?
.create_pool(file.as_fd(), bytes as i32, &qh, ());
let proxy = pool.create_buffer(
0,
width as i32,
height as i32,
stride as i32,
wl_shm::Format::Xrgb8888,
&qh,
(),
);
pool.destroy();
Ok(Buffer {
proxy,
fd: file.into(),
stride,
bytes,
size: (width, height),
_bo: None,
})
}
}
impl Drop for Capture {
fn drop(&mut self) {
if let Some(frame) = self.frame.take() {
frame.destroy();
}
self.session.destroy();
self.source.destroy();
let _ = self.connection.flush();
}
}
fn open_device(device: u64) -> Option<File> {
std::fs::read_dir("/dev/dri")
.ok()?
.filter_map(Result::ok)
.find_map(|entry| {
if !entry.file_name().as_encoded_bytes().starts_with(b"renderD")
|| entry.metadata().ok()?.rdev() != device
{
return None;
}
File::options()
.read(true)
.write(true)
.open(entry.path())
.ok()
})
}
fn dispatch(
connection: &Connection,
queue: &mut EventQueue<State>,
state: &mut State,
timeout: i32,
) -> Result<(), String> {
queue
.dispatch_pending(state)
.map_err(|error| error.to_string())?;
connection.flush().map_err(|error| error.to_string())?;
if let Some(read) = queue.prepare_read() {
let mut fd = libc::pollfd {
fd: read.connection_fd().as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
// SAFETY: poll receives one live descriptor owned by the read guard.
let ready = unsafe { libc::poll(&mut fd, 1, timeout) };
if ready > 0 {
read.read().map_err(|error| error.to_string())?;
} else if ready < 0
&& std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
{
return Err(std::io::Error::last_os_error().to_string());
}
}
queue
.dispatch_pending(state)
.map_err(|error| error.to_string())?;
Ok(())
}
impl Dispatch<wl_registry::WlRegistry, ()> for State {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
match event {
wl_registry::Event::Global {
name,
interface,
version,
} => match interface.as_str() {
"wl_output" => {
state.outputs.insert(
name,
(
registry.bind(name, version.min(4), qh, name),
format!("output-{name}"),
),
);
}
"ext_image_copy_capture_manager_v1" => {
state.manager = Some(registry.bind(name, 1, qh, ()))
}
"ext_output_image_capture_source_manager_v1" => {
state.output_manager = Some(registry.bind(name, 1, qh, ()))
}
"ferese_window_capture_manager_v1" if version >= 2 => {
state.windows = Some(registry.bind(name, 2, qh, ()))
}
"wl_shm" => state.shm = Some(registry.bind(name, 1, qh, ())),
"zwp_linux_dmabuf_v1" if version >= 3 => {
state.dma = Some(registry.bind(name, version.min(4), qh, ()))
}
_ => (),
},
wl_registry::Event::GlobalRemove { name } => {
state.outputs.remove(&name);
if state.selected == Some(name) {
state.stopped = true;
}
}
_ => (),
}
}
}
impl Dispatch<session::ExtImageCopyCaptureSessionV1, ()> for State {
fn event(
state: &mut Self,
_: &session::ExtImageCopyCaptureSessionV1,
event: session::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
session::Event::BufferSize { width, height } => {
state.pending_constraints.size = (width, height)
}
session::Event::ShmFormat {
format: WEnum::Value(wl_shm::Format::Xrgb8888),
} => state.pending_constraints.shm = true,
session::Event::DmabufDevice { device }
if device.len() == std::mem::size_of::<libc::dev_t>() =>
{
let mut bytes = [0u8; 8];
bytes[..device.len()].copy_from_slice(&device);
state.pending_constraints.device = Some(u64::from_ne_bytes(bytes));
}
session::Event::DmabufFormat { format, modifiers }
if format == gbm::Format::Xrgb8888 as u32 =>
{
state.pending_constraints.linear |=
modifiers.chunks_exact(8).any(|bytes| bytes == [0; 8]);
}
session::Event::Done => {
let constraints = std::mem::take(&mut state.pending_constraints);
match constraints.validate() {
Ok(()) => {
state.changed |= state
.constraints
.as_ref()
.is_some_and(|old| old != &constraints);
state.constraints = Some(constraints);
}
Err(error) => state.error = Some(error),
}
}
session::Event::Stopped => state.stopped = true,
_ => (),
}
}
}
impl Dispatch<frame::ExtImageCopyCaptureFrameV1, ()> for State {
fn event(
state: &mut Self,
_: &frame::ExtImageCopyCaptureFrameV1,
event: frame::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
frame::Event::Ready => state.completion = Some(Completion::Ready),
frame::Event::Failed {
reason: WEnum::Value(frame::FailureReason::BufferConstraints),
} => state.completion = Some(Completion::Retry),
frame::Event::Failed { reason } => {
state.completion = Some(Completion::Failed(format!("Capture failed: {reason:?}")))
}
frame::Event::Transform { transform }
if transform != WEnum::Value(wl_output::Transform::Normal) =>
{
state.error = Some("Capture returned a non-upright buffer".into())
}
_ => (),
}
}
}
impl Dispatch<window_manager::FereseWindowCaptureManagerV1, ()> for State {
fn event(
state: &mut Self,
_: &window_manager::FereseWindowCaptureManagerV1,
event: window_manager::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let window_manager::Event::SourceGeometry { width, height, .. } = event {
state.logical_size = Some((width, height));
}
}
}
impl Dispatch<wayland_client::protocol::wl_callback::WlCallback, std::sync::Arc<AtomicBool>>
for State
{
fn event(
_: &mut Self,
_: &wayland_client::protocol::wl_callback::WlCallback,
_: wayland_client::protocol::wl_callback::Event,
done: &std::sync::Arc<AtomicBool>,
_: &Connection,
_: &QueueHandle<Self>,
) {
done.store(true, Ordering::Relaxed);
}
}
impl Dispatch<wl_output::WlOutput, u32> for State {
fn event(
state: &mut Self,
_: &wl_output::WlOutput,
event: wl_output::Event,
id: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_output::Event::Name { name } = event
&& let Some((_, output_name)) = state.outputs.get_mut(id)
{
*output_name = name;
}
}
}
wayland_client::delegate_noop!(State: ignore wl_shm::WlShm);
wayland_client::delegate_noop!(State: ignore wl_shm_pool::WlShmPool);
wayland_client::delegate_noop!(State: ignore wl_buffer::WlBuffer);
wayland_client::delegate_noop!(State: ignore manager::ExtImageCopyCaptureManagerV1);
wayland_client::delegate_noop!(State: ignore output_manager::ExtOutputImageCaptureSourceManagerV1);
wayland_client::delegate_noop!(State: ignore source::ExtImageCaptureSourceV1);
wayland_client::delegate_noop!(State: ignore dmabuf::ZwpLinuxDmabufV1);
wayland_client::delegate_noop!(State: ignore params::ZwpLinuxBufferParamsV1);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn buffer_constraints_are_bounded_without_integer_overflow() {
let mut constraints = Constraints {
size: (640, 480),
shm: true,
..Constraints::default()
};
assert!(constraints.validate().is_ok());
constraints.size = (16384, 2048);
assert!(constraints.validate().is_ok());
for size in [(0, 1), (1, 0), (16384, 2049), (u32::MAX, u32::MAX)] {
constraints.size = size;
assert!(constraints.validate().is_err(), "{size:?}");
}
constraints.size = (640, 480);
constraints.shm = false;
assert!(constraints.validate().is_err());
}
}
+85
View File
@@ -0,0 +1,85 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use pipewire::spa::utils::Fraction;
/// The negotiated capture clock, independent of the output's refresh rate.
pub(super) struct FrameRate {
current: AtomicU64,
pub(super) maximum: u32,
}
impl FrameRate {
pub(super) fn new(value: Option<&str>) -> Result<Self, String> {
let maximum = match value {
None => 60,
Some(value) => value
.parse::<u32>()
.ok()
.filter(|value| (1..=240).contains(value))
.ok_or("FERESE_CAPTURE_MAX_FPS must be an integer from 1 to 240")?,
};
Ok(Self {
current: AtomicU64::new((u64::from(maximum.min(30)) << 32) | 1),
maximum,
})
}
pub(super) fn negotiate(&self, rate: Fraction) -> Result<(), &'static str> {
if rate.denom == 0
|| rate.num < rate.denom
|| u64::from(rate.num) > u64::from(self.maximum) * u64::from(rate.denom)
{
return Err("Negotiated capture rate is outside the advertised range");
}
self.current.store(
(u64::from(rate.num) << 32) | u64::from(rate.denom),
Ordering::Relaxed,
);
Ok(())
}
pub(super) fn fraction(&self) -> Fraction {
let packed = self.current.load(Ordering::Relaxed);
Fraction {
num: (packed >> 32) as u32,
denom: packed as u32,
}
}
pub(super) fn interval(&self) -> Duration {
let rate = self.fraction();
Duration::from_nanos(1_000_000_000 * u64::from(rate.denom) / u64::from(rate.num))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn configuration_bounds_and_default_clock() {
let rate = FrameRate::new(None).unwrap();
assert_eq!(rate.maximum, 60);
assert_eq!(rate.interval(), Duration::from_nanos(33_333_333));
assert_eq!(FrameRate::new(Some("15")).unwrap().fraction().num, 15);
for invalid in ["", "0", "241", "-1", "60.0", " 60"] {
assert!(FrameRate::new(Some(invalid)).is_err(), "{invalid}");
}
}
#[test]
fn consumer_negotiation_preserves_fractional_rates() {
let rate = FrameRate::new(None).unwrap();
rate.negotiate(Fraction {
num: 60_000,
denom: 1001,
})
.unwrap();
assert_eq!(rate.interval(), Duration::from_nanos(16_683_333));
for (num, denom) in [(0, 1), (1, 0), (1, 2), (61, 1), (u32::MAX, 1)] {
assert!(rate.negotiate(Fraction { num, denom }).is_err());
assert_eq!(rate.fraction().num, 60_000);
}
}
}
+30 -1
View File
@@ -7,7 +7,7 @@
requires session opt-in and fails while locked. Window IDs are compositor requires session opt-in and fails while locked. Window IDs are compositor
lifetime IDs and never resolve to replacement windows. lifetime IDs and never resolve to replacement windows.
</description> </description>
<interface name="ferese_window_capture_manager_v1" version="1"> <interface name="ferese_window_capture_manager_v1" version="2">
<request name="destroy" type="destructor"/> <request name="destroy" type="destructor"/>
<request name="capture_window"> <request name="capture_window">
<description summary="snapshot a managed window"> <description summary="snapshot a managed window">
@@ -39,5 +39,34 @@
<arg name="width" type="uint"/> <arg name="width" type="uint"/>
<arg name="height" type="uint"/> <arg name="height" type="uint"/>
</event> </event>
<request name="create_source" since="2">
<description summary="create a persistent window capture source">
Creates a source for ext-image-copy-capture-v1. Unlike a snapshot,
a session waits for content damage and negotiates reusable buffers.
Invalid, closed or protected windows produce a stopped session.
</description>
<arg name="window_hi" type="uint"/>
<arg name="window_lo" type="uint"/>
<arg name="source" type="new_id" interface="ext_image_capture_source_v1"/>
</request>
<request name="refresh_source" since="2">
<description summary="request an initial image after a consumer resumes">
Invalidates the last delivered image for this source's sessions, so
a resumed PipeWire consumer receives an initial image even when the
source is unchanged. Outstanding buffers keep their ownership and GPU
synchronization; this request never cancels a capture in flight.
Subsequent frames continue to wait for source damage.
</description>
<arg name="source" type="object" interface="ext_image_capture_source_v1"/>
</request>
<event name="source_geometry" since="2">
<description summary="logical dimensions of the capture source">
The current logical window size, sent when the source is created.
Subsequent pixel-size changes are announced by the capture session.
</description>
<arg name="source" type="object" interface="ext_image_capture_source_v1"/>
<arg name="width" type="uint"/>
<arg name="height" type="uint"/>
</event>
</interface> </interface>
</protocol> </protocol>
+179
View File
@@ -0,0 +1,179 @@
/* Native PipeWire consumer: require the selected memory type and write packed
* BGRx frames only after the producer has completed GPU work. */
#define _GNU_SOURCE
#include <errno.h>
#include <linux/dma-buf.h>
#include <pipewire/pipewire.h>
#include <spa/param/video/format-utils.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
struct capture {
struct pw_main_loop *loop;
struct pw_stream *stream;
struct spa_hook listener;
struct spa_video_info_raw format;
FILE *file;
unsigned expected_type, frames, wanted;
uint64_t last_sequence;
int64_t last_pts;
int failed;
int obs_buffers;
};
static void fail(struct capture *capture, const char *error) {
fprintf(stderr, "capture-consumer: %s\n", error);
capture->failed = 1;
pw_main_loop_quit(capture->loop);
}
static void state_changed(void *data, enum pw_stream_state old,
enum pw_stream_state state, const char *error) {
(void)old;
struct capture *capture = data;
if (state == PW_STREAM_STATE_ERROR) fail(capture, error ? error : "stream error");
}
static void param_changed(void *data, uint32_t id, const struct spa_pod *param) {
struct capture *capture = data;
if (id != SPA_PARAM_Format || !param) return;
if (spa_format_video_raw_parse(param, &capture->format) < 0 ||
capture->format.format != SPA_VIDEO_FORMAT_BGRx ||
!capture->format.size.width || !capture->format.size.height ||
!!(capture->format.flags & SPA_VIDEO_FLAG_MODIFIER) != (capture->expected_type == SPA_DATA_DmaBuf)) {
fail(capture, "incorrect negotiated video format or memory contract");
return;
}
uint8_t bytes[1024];
struct spa_pod_builder builder = SPA_POD_BUILDER_INIT(bytes, sizeof bytes);
const struct spa_pod *buffers;
if (capture->obs_buffers) {
/* OBS supplies a scalar bitmask containing multiple accepted types.
* Keep this separate from the strict transport test's flags choice. */
buffers = spa_pod_builder_add_object(&builder,
SPA_TYPE_OBJECT_ParamBuffers, SPA_PARAM_Buffers,
SPA_PARAM_BUFFERS_dataType,
SPA_POD_Int((1 << SPA_DATA_MemPtr) | (1 << SPA_DATA_DmaBuf)));
} else {
buffers = spa_pod_builder_add_object(&builder,
SPA_TYPE_OBJECT_ParamBuffers, SPA_PARAM_Buffers,
SPA_PARAM_BUFFERS_buffers, SPA_POD_CHOICE_RANGE_Int(4, 2, 8),
SPA_PARAM_BUFFERS_dataType, SPA_POD_CHOICE_FLAGS_Int(1 << capture->expected_type));
}
const struct spa_pod *header = spa_pod_builder_add_object(&builder,
SPA_TYPE_OBJECT_ParamMeta, SPA_PARAM_Meta,
SPA_PARAM_META_type, SPA_POD_Id(SPA_META_Header),
SPA_PARAM_META_size, SPA_POD_Int(sizeof(struct spa_meta_header)));
const struct spa_pod *parameters[] = {buffers, header};
pw_stream_update_params(capture->stream, parameters, 2);
fprintf(stderr, "capture-consumer: %ux%u %u/%u fps %s\n", capture->format.size.width,
capture->format.size.height, capture->format.framerate.num,
capture->format.framerate.denom, capture->expected_type == SPA_DATA_DmaBuf ? "DMA-BUF" : "SHM");
}
static void process(void *data) {
struct capture *capture = data;
struct pw_buffer *buffer = pw_stream_dequeue_buffer(capture->stream);
if (!buffer) return;
struct spa_buffer *spa = buffer->buffer;
if (spa->n_datas != 1 || !spa->datas[0].chunk) {
fail(capture, "invalid plane count or missing chunk");
goto done;
}
struct spa_data *plane = &spa->datas[0];
if (!plane->chunk->size) goto done;
if (capture->obs_buffers && capture->expected_type == SPA_DATA_MemFd && !plane->data) {
fail(capture, "OBS shared-memory input was not mapped by PipeWire");
goto done;
}
const struct spa_meta_header *header = spa_buffer_find_meta_data(spa, SPA_META_Header, sizeof *header);
if (!header || header->pts < 0 || (capture->frames && (header->seq <= capture->last_sequence || header->pts <= capture->last_pts))) {
fprintf(stderr, "capture-consumer: header=%p frames=%u pts=%lld last_pts=%lld sequence=%llu last_sequence=%llu\n", (void *)header, capture->frames,
(long long)(header ? header->pts : -1), (long long)capture->last_pts,
(unsigned long long)(header ? header->seq : 0), (unsigned long long)capture->last_sequence);
fail(capture, "missing or stale frame sequence/timestamp");
goto done;
}
capture->last_sequence = header->seq;
capture->last_pts = header->pts;
uint32_t row = capture->format.size.width * 4;
uint32_t height = capture->format.size.height;
if (plane->type != capture->expected_type || plane->fd < 0 || plane->chunk->stride < (int)row ||
(uint64_t)plane->chunk->offset + (uint64_t)(height - 1) * plane->chunk->stride + row > plane->maxsize) {
fail(capture, "producer supplied the wrong memory type or invalid plane bounds");
goto done;
}
size_t length = (size_t)plane->mapoffset + plane->maxsize;
void *mapping = mmap(NULL, length, PROT_READ, MAP_SHARED, plane->fd, 0);
if (mapping == MAP_FAILED) { fail(capture, "buffer mapping failed"); goto done; }
struct dma_buf_sync sync = {.flags = DMA_BUF_SYNC_START | DMA_BUF_SYNC_READ};
if (plane->type == SPA_DATA_DmaBuf && ioctl(plane->fd, DMA_BUF_IOCTL_SYNC, &sync)) {
munmap(mapping, length); fail(capture, "DMA-BUF read synchronization failed"); goto done;
}
const uint8_t *pixels = (const uint8_t *)mapping + plane->mapoffset + plane->chunk->offset;
for (uint32_t y = 0; y < height; y++) {
if (fwrite(pixels + (size_t)y * plane->chunk->stride, 1, row, capture->file) != row) {
capture->failed = 1;
break;
}
}
if (plane->type == SPA_DATA_DmaBuf) {
sync.flags = DMA_BUF_SYNC_END | DMA_BUF_SYNC_READ;
if (ioctl(plane->fd, DMA_BUF_IOCTL_SYNC, &sync)) capture->failed = 1;
}
munmap(mapping, length);
fflush(capture->file);
capture->frames++;
if (capture->failed || capture->frames >= capture->wanted) pw_main_loop_quit(capture->loop);
done:
pw_stream_queue_buffer(capture->stream, buffer);
}
int main(int argc, char **argv) {
if (argc != 5) return 2;
pw_init(&argc, &argv);
struct capture capture = {.expected_type = !strcmp(argv[2], "dma") ? SPA_DATA_DmaBuf : SPA_DATA_MemFd,
.wanted = strtoul(argv[3], NULL, 10),
.obs_buffers = getenv("FERESE_TEST_CAPTURE_OBS_BUFFERS") != NULL};
capture.file = fopen(argv[4], "wb");
capture.loop = pw_main_loop_new(NULL);
if (!capture.file || !capture.loop || !capture.wanted) return 3;
struct pw_context *context = pw_context_new(pw_main_loop_get_loop(capture.loop), NULL, 0);
struct pw_core *core = pw_context_connect(context, NULL, 0);
if (!core) return 4;
capture.stream = pw_stream_new(core, "Ferese capture regression", pw_properties_new(PW_KEY_MEDIA_TYPE, "Video", PW_KEY_MEDIA_CATEGORY, "Capture", NULL));
const struct pw_stream_events events = {.version = PW_VERSION_STREAM_EVENTS,
.state_changed = state_changed, .param_changed = param_changed, .process = process};
pw_stream_add_listener(capture.stream, &capture.listener, &events, &capture);
uint8_t bytes[1024];
struct spa_pod_builder builder = SPA_POD_BUILDER_INIT(bytes, sizeof bytes);
const struct spa_pod *format;
if (capture.expected_type == SPA_DATA_DmaBuf) {
format = spa_pod_builder_add_object(&builder, SPA_TYPE_OBJECT_Format, SPA_PARAM_EnumFormat,
SPA_FORMAT_mediaType, SPA_POD_Id(SPA_MEDIA_TYPE_video),
SPA_FORMAT_mediaSubtype, SPA_POD_Id(SPA_MEDIA_SUBTYPE_raw),
SPA_FORMAT_VIDEO_format, SPA_POD_Id(SPA_VIDEO_FORMAT_BGRx),
SPA_FORMAT_VIDEO_modifier, SPA_POD_Long(0),
SPA_FORMAT_VIDEO_framerate, SPA_POD_Fraction(&SPA_FRACTION(60, 1)));
} else {
format = spa_pod_builder_add_object(&builder, SPA_TYPE_OBJECT_Format, SPA_PARAM_EnumFormat,
SPA_FORMAT_mediaType, SPA_POD_Id(SPA_MEDIA_TYPE_video),
SPA_FORMAT_mediaSubtype, SPA_POD_Id(SPA_MEDIA_SUBTYPE_raw),
SPA_FORMAT_VIDEO_format, SPA_POD_Id(SPA_VIDEO_FORMAT_BGRx),
SPA_FORMAT_VIDEO_framerate, SPA_POD_Fraction(&SPA_FRACTION(60, 1)));
}
enum pw_stream_flags stream_flags = PW_STREAM_FLAG_AUTOCONNECT;
if (capture.obs_buffers) stream_flags |= PW_STREAM_FLAG_MAP_BUFFERS;
int result = pw_stream_connect(capture.stream, PW_DIRECTION_INPUT, strtoul(argv[1], NULL, 10), stream_flags, &format, 1);
if (result >= 0) pw_main_loop_run(capture.loop);
else capture.failed = 1;
pw_stream_destroy(capture.stream);
pw_core_disconnect(core);
pw_context_destroy(context);
pw_main_loop_destroy(capture.loop);
fclose(capture.file);
return capture.failed || capture.frames != capture.wanted;
}
+148
View File
@@ -0,0 +1,148 @@
/* Native-pixel stripes distinguish a preserved backing buffer from a resample. */
#define _GNU_SOURCE
#include <stdint.h>
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
#include <wayland-client.h>
#include "xdg-shell-client-protocol.h"
#include "viewporter-client-protocol.h"
static struct wl_compositor *compositor;
static struct wl_subcompositor *subcompositor;
static struct wl_shm *shm;
static struct xdg_wm_base *wm;
static struct wp_viewporter *viewporter;
static struct wl_surface *surface;
static struct wl_buffer *buffer;
static void ping(void *data, struct xdg_wm_base *base, uint32_t serial) {
(void)data;
xdg_wm_base_pong(base, serial);
}
static const struct xdg_wm_base_listener wm_listener = {.ping = ping};
static void global(void *data, struct wl_registry *registry, uint32_t name,
const char *interface, uint32_t version) {
(void)data; (void)version;
if (!strcmp(interface, "wl_compositor"))
compositor = wl_registry_bind(registry, name, &wl_compositor_interface, 4);
else if (!strcmp(interface, "wl_subcompositor"))
subcompositor = wl_registry_bind(registry, name, &wl_subcompositor_interface, 1);
else if (!strcmp(interface, "wl_shm"))
shm = wl_registry_bind(registry, name, &wl_shm_interface, 1);
else if (!strcmp(interface, "wp_viewporter"))
viewporter = wl_registry_bind(registry, name, &wp_viewporter_interface, 1);
else if (!strcmp(interface, "xdg_wm_base")) {
wm = wl_registry_bind(registry, name, &xdg_wm_base_interface, 1);
xdg_wm_base_add_listener(wm, &wm_listener, NULL);
}
}
static void removed(void *data, struct wl_registry *registry, uint32_t name) {
(void)data; (void)registry; (void)name;
}
static const struct wl_registry_listener registry_listener = {global, removed};
static void configure(void *data, struct xdg_surface *xdg, uint32_t serial) {
(void)data;
xdg_surface_ack_configure(xdg, serial);
xdg_surface_set_window_geometry(xdg, 0, 0, 640, 480);
wl_surface_attach(surface, buffer, 0, 0);
wl_surface_damage_buffer(surface, 0, 0, INT32_MAX, INT32_MAX);
wl_surface_commit(surface);
}
static const struct xdg_surface_listener surface_listener = {.configure = configure};
static void size(void *data, struct xdg_toplevel *top, int32_t w, int32_t h,
struct wl_array *states) {
(void)data; (void)top; (void)w; (void)h; (void)states;
}
static void close_window(void *data, struct xdg_toplevel *top) {
(void)data; (void)top; exit(0);
}
static const struct xdg_toplevel_listener top_listener = {.configure = size, .close = close_window};
int main(int argc, char **argv) {
if (argc != 2) return 1;
struct wl_display *display = wl_display_connect(NULL);
if (!display) return 2;
struct wl_registry *registry = wl_display_get_registry(display);
wl_registry_add_listener(registry, &registry_listener, NULL);
if (wl_display_roundtrip(display) < 0 || !compositor || !shm || !wm || !viewporter) return 3;
int integer = !strcmp(argv[1], "integer");
int fractional = !strcmp(argv[1], "fractional");
int cropped = !strcmp(argv[1], "cropped");
int width = fractional ? 1120 : 1280;
int height = fractional ? 840 : 960;
int fd = memfd_create("window-capture-scale", MFD_CLOEXEC);
size_t bytes = (size_t)width * height * 4;
if (fd < 0 || ftruncate(fd, bytes)) return 4;
uint32_t *pixels = mmap(NULL, bytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (pixels == MAP_FAILED) return 5;
for (int y = 0; y < height; y++)
for (int x = 0; x < width; x++)
pixels[y * width + x] = x % 2 ? 0xffffffff : 0xff000000;
struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, bytes);
buffer = wl_shm_pool_create_buffer(pool, 0, width, height, width * 4, WL_SHM_FORMAT_ARGB8888);
wl_shm_pool_destroy(pool);
munmap(pixels, bytes);
close(fd);
surface = wl_compositor_create_surface(compositor);
if (!strcmp(argv[1], "subsurface") || !strcmp(argv[1], "subsurface-outside")) {
if (!subcompositor) return 6;
struct wl_surface *child = wl_compositor_create_surface(compositor);
struct wl_subsurface *sub = wl_subcompositor_get_subsurface(subcompositor, child, surface);
if (!strcmp(argv[1], "subsurface-outside"))
wl_subsurface_set_position(sub, 1280, 0);
wl_surface_set_buffer_scale(child, 2);
wl_surface_attach(child, buffer, 0, 0);
wl_surface_damage_buffer(child, 0, 0, INT32_MAX, INT32_MAX);
wl_surface_commit(child);
// A transparent placeholder makes the child's density authoritative.
int root_fd = memfd_create("capture-scale-root", MFD_CLOEXEC);
if (root_fd < 0 || ftruncate(root_fd, 4)) return 7;
uint32_t *root_pixels = mmap(NULL, 4, PROT_READ | PROT_WRITE, MAP_SHARED, root_fd, 0);
if (root_pixels == MAP_FAILED) return 8;
*root_pixels = 0;
struct wl_shm_pool *root_pool = wl_shm_create_pool(shm, root_fd, 4);
buffer = wl_shm_pool_create_buffer(root_pool, 0, 1, 1, 4, WL_SHM_FORMAT_ARGB8888);
wl_shm_pool_destroy(root_pool);
munmap(root_pixels, 4);
close(root_fd);
}
if (integer) {
wl_surface_set_buffer_scale(surface, 2);
} else {
struct wp_viewport *viewport = wp_viewporter_get_viewport(viewporter, surface);
wp_viewport_set_destination(viewport, 640, 480);
if (cropped)
wp_viewport_set_source(viewport, wl_fixed_from_int(320), wl_fixed_from_int(240),
wl_fixed_from_int(640), wl_fixed_from_int(480));
}
struct xdg_surface *xdg = xdg_wm_base_get_xdg_surface(wm, surface);
xdg_surface_add_listener(xdg, &surface_listener, NULL);
struct xdg_toplevel *top = xdg_surface_get_toplevel(xdg);
xdg_toplevel_add_listener(top, &top_listener, NULL);
xdg_toplevel_set_app_id(top, "ferese.test.capture-scale");
xdg_toplevel_set_title(top, argv[1]);
wl_surface_commit(surface);
while (1) {
wl_display_flush(display);
struct pollfd fds[2] = {{wl_display_get_fd(display), POLLIN, 0}, {STDIN_FILENO, POLLIN, 0}};
if (poll(fds, 2, -1) < 0) break;
if ((fds[0].revents | fds[1].revents) & (POLLERR | POLLHUP | POLLNVAL)) break;
if (fds[0].revents & POLLIN && wl_display_dispatch(display) < 0) break;
if (fds[1].revents & POLLIN) {
char command;
if (read(STDIN_FILENO, &command, 1) != 1) break;
if (command == 'd') {
wl_surface_attach(surface, buffer, 0, 0);
wl_surface_damage(surface, 0, 0, 640, 480);
wl_surface_commit(surface);
}
}
}
return 0;
}
+44 -4
View File
@@ -18,6 +18,9 @@ static struct wl_buffer *buffer;
static int width = 640, height = 480; static int width = 640, height = 480;
static int private_capture_seen; static int private_capture_seen;
static int covered; static int covered;
static unsigned updates;
static unsigned animation_frames;
static void make_buffer(void);
static void ping(void *data, struct xdg_wm_base *base, uint32_t serial) { static void ping(void *data, struct xdg_wm_base *base, uint32_t serial) {
(void)data; (void)data;
@@ -28,7 +31,9 @@ static const struct xdg_wm_base_listener wm_listener = {.ping = ping};
static void global(void *data, struct wl_registry *registry, uint32_t name, static void global(void *data, struct wl_registry *registry, uint32_t name,
const char *interface, uint32_t version) { const char *interface, uint32_t version) {
(void)data; (void)version; (void)data; (void)version;
if (!strcmp(interface, "ferese_window_capture_manager_v1")) private_capture_seen = 1; if (!strcmp(interface, "ferese_window_capture_manager_v1") ||
!strcmp(interface, "ext_image_copy_capture_manager_v1") ||
!strcmp(interface, "ext_output_image_capture_source_manager_v1")) private_capture_seen = 1;
if (!strcmp(interface, "wl_compositor")) if (!strcmp(interface, "wl_compositor"))
compositor = wl_registry_bind(registry, name, &wl_compositor_interface, 4); compositor = wl_registry_bind(registry, name, &wl_compositor_interface, 4);
else if (!strcmp(interface, "wl_shm")) else if (!strcmp(interface, "wl_shm"))
@@ -73,6 +78,7 @@ static void make_buffer(void) {
for (size_t i = 0; i < (size_t)width * height; i++) { for (size_t i = 0; i < (size_t)width * height; i++) {
pixels[i] = covered ? 0xff00ff00 : (i % width < 80 ? 0x80800000 : (i / width < (size_t)height / 2 ? 0xffff0000 : 0xff0000ff)); pixels[i] = covered ? 0xff00ff00 : (i % width < 80 ? 0x80800000 : (i / width < (size_t)height / 2 ? 0xffff0000 : 0xff0000ff));
} }
if (updates) pixels[(size_t)(height / 2) * width + width / 2] = 0xff000000 | updates;
struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, bytes); struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, bytes);
buffer = wl_shm_pool_create_buffer(pool, 0, width, height, width * 4, WL_SHM_FORMAT_ARGB8888); buffer = wl_shm_pool_create_buffer(pool, 0, width, height, width * 4, WL_SHM_FORMAT_ARGB8888);
wl_shm_pool_destroy(pool); wl_shm_pool_destroy(pool);
@@ -80,6 +86,23 @@ static void make_buffer(void) {
munmap(pixels, bytes); munmap(pixels, bytes);
} }
static void animate(void *data, struct wl_callback *callback, uint32_t time) {
(void)data; (void)time;
wl_callback_destroy(callback);
updates++;
wl_buffer_destroy(buffer);
make_buffer();
wl_surface_attach(surface, buffer, 0, 0);
wl_surface_damage(surface, width / 2, height / 2, 1, 1);
if (--animation_frames) {
struct wl_callback *next = wl_surface_frame(surface);
static const struct wl_callback_listener listener = {.done = animate};
wl_callback_add_listener(next, &listener, NULL);
}
wl_surface_commit(surface);
}
static const struct wl_callback_listener animation_listener = {.done = animate};
int main(int argc, char **argv) { int main(int argc, char **argv) {
struct wl_display *display = wl_display_connect(NULL); struct wl_display *display = wl_display_connect(NULL);
if (!display) return 1; if (!display) return 1;
@@ -97,17 +120,34 @@ int main(int argc, char **argv) {
xdg_toplevel_set_app_id(top, "ferese.test.window-capture"); xdg_toplevel_set_app_id(top, "ferese.test.window-capture");
xdg_toplevel_set_title(top, covered ? "Occluding window" : "Capture target"); xdg_toplevel_set_title(top, covered ? "Occluding window" : "Capture target");
wl_surface_commit(surface); wl_surface_commit(surface);
if (argc > 1 && !strcmp(argv[1], "resize")) { if ((argc > 1 && (!strcmp(argv[1], "resize") || !strcmp(argv[1], "interactive"))) || (argc > 2 && !strcmp(argv[2], "interactive"))) {
while (1) { while (1) {
wl_display_flush(display); wl_display_flush(display);
struct pollfd fds[2] = {{wl_display_get_fd(display), POLLIN, 0}, {STDIN_FILENO, POLLIN, 0}}; struct pollfd fds[2] = {{wl_display_get_fd(display), POLLIN, 0}, {STDIN_FILENO, POLLIN, 0}};
if (poll(fds, 2, -1) < 0) break; if (poll(fds, 2, -1) < 0) break;
if ((fds[0].revents | fds[1].revents) & (POLLERR | POLLHUP | POLLNVAL)) break;
if (fds[0].revents & POLLIN && wl_display_dispatch(display) < 0) break; if (fds[0].revents & POLLIN && wl_display_dispatch(display) < 0) break;
if (fds[1].revents & POLLIN) { if (fds[1].revents & POLLIN) {
char command; char command;
if (read(STDIN_FILENO, &command, 1) != 1) break; if (read(STDIN_FILENO, &command, 1) != 1) break;
if (command == 'r') { if (command == 'a') {
width = 480; height = 320; animation_frames = 3;
struct wl_callback *next = wl_surface_frame(surface);
wl_callback_add_listener(next, &animation_listener, NULL);
// Start an animation by requesting a callback; pixels have
// not changed yet. A capture must not leave this waiting.
wl_surface_commit(surface);
}
if (command == 'd') {
updates++;
wl_buffer_destroy(buffer);
make_buffer();
wl_surface_attach(surface, buffer, 0, 0);
wl_surface_damage(surface, width / 2, height / 2, 1, 1);
wl_surface_commit(surface);
}
if (command == 'r' || command == 'o') {
width = 480 + (command == 'o'); height = 320 + (command == 'o');
wl_buffer_destroy(buffer); wl_buffer_destroy(buffer);
make_buffer(); make_buffer();
xdg_surface_set_window_geometry(xdg, 0, 0, width, height); xdg_surface_set_window_geometry(xdg, 0, 0, width, height);
+20 -1
View File
@@ -34,11 +34,12 @@ class PortalTest(unittest.TestCase):
runtime.mkdir(mode=0o700) runtime.mkdir(mode=0o700)
config = root / "config/ferese" config = root / "config/ferese"
config.mkdir(parents=True) config.mkdir(parents=True)
(config / "config.kdl").write_text('window-rule app-id="dev.ferese.ScreenShare" floating=#true\n') (config / "config.kdl").write_text('window-rule app-id="dev.ferese.ScreenShare" floating=#true\nwindow-rule app-id="ferese.test.window-capture" floating=#true\n')
display = Path(os.environ["WAYLAND_DISPLAY"]) display = Path(os.environ["WAYLAND_DISPLAY"])
if not display.is_absolute(): if not display.is_absolute():
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"),
XDG_STATE_HOME=str(root / "state"), XDG_CACHE_HOME=str(root / "cache"), XDG_DATA_HOME=str(root / "data"),
WAYLAND_DISPLAY=str(display), FERESE_ENABLE_SCREENCOPY="1", WAYLAND_DISPLAY=str(display), FERESE_ENABLE_SCREENCOPY="1",
PIPEWIRE_RUNTIME_DIR=os.environ["XDG_RUNTIME_DIR"], XDG_CURRENT_DESKTOP="Ferese", PIPEWIRE_RUNTIME_DIR=os.environ["XDG_RUNTIME_DIR"], XDG_CURRENT_DESKTOP="Ferese",
XDG_DESKTOP_PORTAL_DIR=str(repo / "packaging/portal")) XDG_DESKTOP_PORTAL_DIR=str(repo / "packaging/portal"))
@@ -68,6 +69,14 @@ class PortalTest(unittest.TestCase):
return json.loads(worker.stdout.readline())["node"] return json.loads(worker.stdout.readline())["node"]
def consume(node, fd=None): def consume(node, fd=None):
import threading
producing = threading.Event()
def update_source():
while not producing.wait(.05):
fixture.stdin.write(b"d")
fixture.stdin.flush()
updates = threading.Thread(target=update_source)
updates.start()
source = f'pipewiresrc path={node} num-buffers=15' source = f'pipewiresrc path={node} num-buffers=15'
if fd is not None: if fd is not None:
source += f' fd={fd}' source += f' fd={fd}'
@@ -90,6 +99,8 @@ class PortalTest(unittest.TestCase):
self.assertGreater(timestamps[-1] - timestamps[0], Gst.SECOND // 5) self.assertGreater(timestamps[-1] - timestamps[0], Gst.SECOND // 5)
finally: finally:
pipeline.set_state(Gst.State.NULL) pipeline.set_state(Gst.State.NULL)
producing.set()
updates.join(timeout=2)
try: try:
launch("compositor", [str(compositor_binary), "--backend", "nested"]) launch("compositor", [str(compositor_binary), "--backend", "nested"])
@@ -98,6 +109,14 @@ class PortalTest(unittest.TestCase):
until(lambda: ipc_socket(runtime).is_socket()) until(lambda: ipc_socket(runtime).is_socket())
env["WAYLAND_DISPLAY"] = str(sockets()[0]) env["WAYLAND_DISPLAY"] = str(sockets()[0])
env = ipc_environment(env) env = ipc_environment(env)
protocols = subprocess.check_output(["pkg-config", "--variable=pkgdatadir", "wayland-protocols"], text=True).strip()
xml = str(Path(protocols) / "stable/xdg-shell/xdg-shell.xml")
for kind, output in [("client-header", "xdg-shell-client-protocol.h"), ("private-code", "xdg-shell-protocol.c")]:
subprocess.run(["wayland-scanner", kind, xml, str(root / output)], check=True)
flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "wayland-client"], text=True).split()
subprocess.run(["cc", "-Wall", "-Wextra", "-I", str(root), str(repo / "scripts/tests/fixtures/window-capture.c"),
str(root / "xdg-shell-protocol.c"), "-o", str(root / "source"), *flags], check=True)
fixture = launch("source", [str(root / "source"), "interactive"], stdin=subprocess.PIPE)
# Private bus, including the real portal frontend and permission store. # Private bus, including the real portal frontend and permission store.
bus = launch("bus", ["dbus-daemon", "--session", "--nofork", "--print-address=1"], stdout=subprocess.PIPE) bus = launch("bus", ["dbus-daemon", "--session", "--nofork", "--print-address=1"], stdout=subprocess.PIPE)
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().decode().strip() env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().decode().strip()
+192 -12
View File
@@ -9,6 +9,7 @@ import io
import json import json
import os import os
from pathlib import Path from pathlib import Path
import re
from session_socket import ipc_environment, ipc_socket from session_socket import ipc_environment, ipc_socket
import signal import signal
import selectors import selectors
@@ -54,7 +55,7 @@ class WindowCapture(unittest.TestCase):
display = Path(os.environ["WAYLAND_DISPLAY"]) display = Path(os.environ["WAYLAND_DISPLAY"])
if not display.is_absolute(): if not display.is_absolute():
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"), WAYLAND_DISPLAY=str(display), FERESE_ENABLE_SCREENCOPY="1") env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"), WAYLAND_DISPLAY=str(display), FERESE_ENABLE_SCREENCOPY="1", FERESE_TRACE_PERFORMANCE="1")
env.pop("WAYLAND_SOCKET", None) env.pop("WAYLAND_SOCKET", None)
env.pop("FERESE_SHELL_CONTROL_SOCKET", None) env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
log = (root / "compositor.log").open("w") log = (root / "compositor.log").open("w")
@@ -72,18 +73,20 @@ class WindowCapture(unittest.TestCase):
def call(*args): def call(*args):
return subprocess.check_output([str(CTL), "-j", *args], env=ipc_environment(env), timeout=10) return subprocess.check_output([str(CTL), "-j", *args], env=ipc_environment(env), timeout=10)
def windows(count): def windows(count, width=640):
deadline = time.monotonic() + 10 deadline = time.monotonic() + 10
while time.monotonic() < deadline: while time.monotonic() < deadline:
result = json.loads(call("get-windows")) result = json.loads(call("get-windows"))
if len(result) == count and all(window["mapped"] and window["width"] == 640 for window in result): if len(result) == count and all(window["mapped"] and (width is None or window["width"] == width) for window in result):
return result return result
time.sleep(0.02) time.sleep(0.02)
self.fail((root / "compositor.log").read_text()) self.fail((root / "compositor.log").read_text())
processes.append(subprocess.Popen([str(root / "client")], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True)) target_client = subprocess.Popen([str(root / "client"), "interactive"], env=ipc_environment(env), stdin=subprocess.PIPE, stdout=log, stderr=log, start_new_session=True)
processes.append(target_client)
target = windows(1)[0] target = windows(1)[0]
processes.append(subprocess.Popen([str(root / "client"), "cover"], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True)) cover_client = subprocess.Popen([str(root / "client"), "cover", "interactive"], env=ipc_environment(env), stdin=subprocess.PIPE, stdout=log, stderr=log, start_new_session=True)
processes.append(cover_client)
current = windows(2) current = windows(2)
cover = next(window for window in current if window["title"] == "Occluding window") cover = next(window for window in current if window["title"] == "Occluding window")
self.assertEqual((target["x"], target["y"], target["width"], target["height"]), self.assertEqual((target["x"], target["y"], target["width"], target["height"]),
@@ -96,8 +99,12 @@ class WindowCapture(unittest.TestCase):
self.assertEqual(image.convert("RGBA").getpixel((round(40 * scale), round(80 * scale))), (255, 0, 0, 128)) self.assertEqual(image.convert("RGBA").getpixel((round(40 * scale), round(80 * scale))), (255, 0, 0, 128))
with Image.open(io.BytesIO(call("screenshot-window", str(cover["id"])))) as image: with Image.open(io.BytesIO(call("screenshot-window", str(cover["id"])))) as image:
self.assertEqual(image.convert("RGB").getpixel((image.width // 2, image.height // 2)), (0, 255, 0)) self.assertEqual(image.convert("RGB").getpixel((image.width // 2, image.height // 2)), (0, 255, 0))
# Exercise the asynchronous conversion worker and reuse its render # Capture an inactive workspace, where display repaint callbacks
# target across a stream, not just the synchronous screenshot path. # cannot conceal a stalled capture-driven animation.
call("workspace", "2")
# Verify real streaming updates and silence between commits. Each
# requested frame changes a pixel; static content must not be copied
# repeatedly just to satisfy a consumer's process callbacks.
stream_env = dict(env, PIPEWIRE_REMOTE=str(Path(os.environ["XDG_RUNTIME_DIR"]) / "pipewire-0")) stream_env = dict(env, PIPEWIRE_REMOTE=str(Path(os.environ["XDG_RUNTIME_DIR"]) / "pipewire-0"))
worker = subprocess.Popen([str(PORTAL), worker = subprocess.Popen([str(PORTAL),
"--stream-window", str(target["id"]), "hidden"], "--stream-window", str(target["id"]), "hidden"],
@@ -110,9 +117,37 @@ class WindowCapture(unittest.TestCase):
ready = json.loads(worker.stdout.readline()) ready = json.loads(worker.stdout.readline())
self.assertEqual((ready["width"], ready["height"]), (round(640 * scale), round(480 * scale))) self.assertEqual((ready["width"], ready["height"]), (round(640 * scale), round(480 * scale)))
raw = root / "frames.bgrx" raw = root / "frames.bgrx"
subprocess.run(["gst-launch-1.0", "-q", "pipewiresrc", "min-buffers=4", f'path={ready["node"]}', transport = os.environ.get("FERESE_TEST_CAPTURE_TRANSPORT")
"num-buffers=15", "!", "video/x-raw,format=BGRx", "!", "filesink", if transport:
f"location={raw}"], env=stream_env, check=True, timeout=20) flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "libpipewire-0.3"], text=True).split()
subprocess.run(["cc", "-Wall", "-Wextra", str(REPO / "scripts/tests/fixtures/capture-consumer.c"), "-o", str(root / "consumer"), *flags], check=True)
command = [str(root / "consumer"), str(ready["node"]), transport, "15", str(raw)]
else:
command = ["gst-launch-1.0", "-q", "pipewiresrc", "min-buffers=4", f'path={ready["node"]}',
"num-buffers=15", "!", "video/x-raw,format=BGRx", "!", "filesink", "buffer-mode=unbuffered", f"location={raw}"]
consumer = subprocess.Popen(command, env=stream_env, stdout=log, stderr=log, start_new_session=True)
processes.append(consumer)
frame_bytes = ready["width"] * ready["height"] * 4
def wait_frames(count):
deadline = time.monotonic() + 7
while not raw.exists() or raw.stat().st_size < count * frame_bytes:
if consumer.poll() is not None or worker.poll() is not None or time.monotonic() >= deadline:
self.fail((root / "compositor.log").read_text())
time.sleep(0.01)
wait_frames(1)
time.sleep(0.35)
self.assertEqual(raw.stat().st_size, frame_bytes, "unchanged window produced duplicate frames")
target_client.stdin.write(b"a")
target_client.stdin.flush()
wait_frames(4)
for count in range(5, 16):
target_client.stdin.write(b"d")
target_client.stdin.flush()
wait_frames(count)
consumer.wait(timeout=7)
self.assertEqual(consumer.returncode, 0)
pixels = raw.read_bytes() pixels = raw.read_bytes()
width, height = ready["width"], ready["height"] width, height = ready["width"], ready["height"]
frame_bytes = width * height * 4 frame_bytes = width * height * 4
@@ -123,6 +158,59 @@ class WindowCapture(unittest.TestCase):
blue = start + ((height * 3 // 4) * width + width // 2) * 4 blue = start + ((height * 3 // 4) * width + width // 2) * 4
self.assertEqual(pixels[red:red + 3], bytes([0, 0, 255])) self.assertEqual(pixels[red:red + 3], bytes([0, 0, 255]))
self.assertEqual(pixels[blue:blue + 3], bytes([255, 0, 0])) self.assertEqual(pixels[blue:blue + 3], bytes([255, 0, 0]))
call("workspace", "1")
# A new consumer must receive an initial image even when the
# source has stayed unchanged since the previous consumer left.
original_command = command.copy()
raw = root / "reconnected.bgrx"
if transport:
command[3] = "1"
command[-1] = str(raw)
else:
command[command.index("num-buffers=15")] = "num-buffers=1"
command[-1] = f"location={raw}"
consumer = subprocess.Popen(command, env=stream_env, stdout=log, stderr=log, start_new_session=True)
processes.append(consumer)
wait_frames(1)
consumer.wait(timeout=7)
self.assertEqual(consumer.returncode, 0)
self.assertEqual(raw.stat().st_size, frame_bytes)
command = original_command
# Resize while the consumer is disconnected, then reconnect to
# the same node. Retired pool buffers must never be queued again.
resize_width, resize_height = ((481, 321) if transport else (480, 320))
target_client.stdin.write(b"o" if transport else b"r")
target_client.stdin.flush()
deadline = time.monotonic() + 7
while not any(w["id"] == target["id"] and w["width"] == resize_width for w in json.loads(call("windows"))):
if time.monotonic() >= deadline:
self.fail("Window did not resize")
time.sleep(.02)
raw = root / "resized.bgrx"
if transport:
command[-1] = str(raw)
else:
command[-1] = f"location={raw}"
consumer = subprocess.Popen(command, env=stream_env, stdout=log, stderr=log, start_new_session=True)
processes.append(consumer)
width, height = round(resize_width * scale), round(resize_height * scale)
frame_bytes = width * height * 4
wait_frames(1)
time.sleep(.35)
self.assertEqual(raw.stat().st_size, frame_bytes, "paused resize left a stale frame in the stream")
for count in range(2, 16):
target_client.stdin.write(b"d")
target_client.stdin.flush()
wait_frames(count)
consumer.wait(timeout=7)
self.assertEqual(consumer.returncode, 0)
resized = raw.read_bytes()
self.assertEqual(len(resized), frame_bytes * 15)
for index in (0, 14):
red = index * frame_bytes + ((height // 4) * width + width // 2) * 4
blue = index * frame_bytes + ((height * 3 // 4) * width + width // 2) * 4
self.assertEqual(resized[red:red + 3], bytes([0, 0, 255]))
self.assertEqual(resized[blue:blue + 3], bytes([255, 0, 0]))
# A live rule change revokes the window stream and synchronous # A live rule change revokes the window stream and synchronous
# window screenshot route, while normal display mapping remains. # window screenshot route, while normal display mapping remains.
config.write_text('window-rule app-id="ferese.test.window-capture" floating=#true\n' config.write_text('window-rule app-id="ferese.test.window-capture" floating=#true\n'
@@ -135,12 +223,12 @@ class WindowCapture(unittest.TestCase):
env=ipc_environment(env), capture_output=True, timeout=10) env=ipc_environment(env), capture_output=True, timeout=10)
self.assertNotEqual(rejected.returncode, 0) self.assertNotEqual(rejected.returncode, 0)
self.assertIn(b"protected", rejected.stderr) self.assertIn(b"protected", rejected.stderr)
self.assertTrue(next(window for window in windows(2) if window["id"] == target["id"])["mapped"]) self.assertTrue(next(window for window in windows(2, width=None) if window["id"] == target["id"])["mapped"])
with Image.open(io.BytesIO(call("screenshot-window", str(cover["id"])))) as image: with Image.open(io.BytesIO(call("screenshot-window", str(cover["id"])))) as image:
self.assertEqual(image.convert("RGB").getpixel((image.width // 2, image.height // 2)), (0, 255, 0)) self.assertEqual(image.convert("RGB").getpixel((image.width // 2, image.height // 2)), (0, 255, 0))
os.killpg(processes[2].pid, signal.SIGTERM) os.killpg(processes[2].pid, signal.SIGTERM)
processes[2].wait(timeout=5) processes[2].wait(timeout=5)
protected = windows(1)[0] protected = windows(1, width=resize_width)[0]
self.assertTrue(protected["mapped"]) self.assertTrue(protected["mapped"])
for args in [("screenshot",), ("screenshot", "--geometry", for args in [("screenshot",), ("screenshot", "--geometry",
f'{protected["x"]},{protected["y"]} {protected["width"]}x{protected["height"]}')]: f'{protected["x"]},{protected["y"]} {protected["width"]}x{protected["height"]}')]:
@@ -152,11 +240,103 @@ class WindowCapture(unittest.TestCase):
call("reload-config") call("reload-config")
with Image.open(io.BytesIO(call("screenshot-window", str(target["id"])))) as image: with Image.open(io.BytesIO(call("screenshot-window", str(target["id"])))) as image:
self.assertEqual(image.convert("RGB").getpixel((image.width // 2, image.height // 4)), (255, 0, 0)) self.assertEqual(image.convert("RGB").getpixel((image.width // 2, image.height // 4)), (255, 0, 0))
# Updates behind an opaque private window still belong in the
# filtered capture, even when display occlusion hides them.
config.write_text('window-rule app-id="ferese.test.window-capture" floating=#true\n'
'window-rule title="Occluding window" block-out-from-screencasts=#true\n')
call("reload-config")
private_cover = subprocess.Popen([str(root / "client"), "cover", "interactive"],
env=ipc_environment(env), stdin=subprocess.PIPE,
stdout=log, stderr=log, start_new_session=True)
processes.append(private_cover)
pair = windows(2, width=None)
cover = next(window for window in pair if window["title"] == "Occluding window")
target = next(window for window in pair if window["id"] == target["id"])
marker_x = target["x"] + target["width"] / 2
marker_y = target["y"] + target["height"] / 2
self.assertLess(cover["x"], marker_x)
self.assertGreater(cover["x"] + cover["width"], marker_x)
self.assertLess(cover["y"], marker_y)
self.assertGreater(cover["y"] + cover["height"], marker_y)
# Monitor capture uses an independent filtered scene and the same
# buffer transport. It must also wait quietly between real updates.
# Adding the occluder and reloading rules can animate focus
# borders. Those are real damage, so establish a settled scene
# before asserting that an unchanged monitor produces no frames.
previous, stable_since = None, time.monotonic()
deadline = time.monotonic() + 7
while time.monotonic() < deadline:
image = call("screenshot")
if image != previous:
previous, stable_since = image, time.monotonic()
elif time.monotonic() - stable_since >= .35:
break
time.sleep(.04)
else:
self.fail("Monitor did not settle before the damage-waiting check")
sources = json.loads(subprocess.check_output([str(PORTAL), "--sources"], env=stream_env, timeout=7))
worker = subprocess.Popen([str(PORTAL), "--stream", sources[0]["name"], "hidden"], env=stream_env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log, start_new_session=True)
processes.append(worker)
with selectors.DefaultSelector() as selector:
selector.register(worker.stdout, selectors.EVENT_READ)
self.assertTrue(selector.select(10), log.name)
ready = json.loads(worker.stdout.readline())
raw = root / "monitor.bgrx"
frame_bytes = ready["width"] * ready["height"] * 4
if transport:
command = [str(root / "consumer"), str(ready["node"]), transport, "15", str(raw)]
else:
command = ["gst-launch-1.0", "-q", "pipewiresrc", f'path={ready["node"]}', "num-buffers=15", "!",
"video/x-raw,format=BGRx", "!", "filesink", "buffer-mode=unbuffered", f"location={raw}"]
consumer = subprocess.Popen(command, env=stream_env, stdout=log, stderr=log, start_new_session=True)
processes.append(consumer)
wait_frames(1)
# Wait for a real compositor report, rather than inferring GPU
# reuse merely from the absence of delivered duplicate frames.
deadline = time.monotonic() + 7
while time.monotonic() < deadline:
reports = [line for line in (root / "compositor.log").read_text().splitlines()
if "capture scene outcomes" in line and "include_cursor=false" in line]
if any(int(match.group(1)) > 0 for line in reports
if (match := re.search(r"scene_reuses=(\d+)", line))):
break
time.sleep(.05)
else:
self.fail("Unchanged capture never reported reusing its filtered scene:\n"
+ (root / "compositor.log").read_text())
monitor_idle = raw.read_bytes()
self.assertEqual(len(monitor_idle), frame_bytes,
"unchanged monitor produced extra frames; first two images equal="
+ str(monitor_idle[:frame_bytes] == monitor_idle[frame_bytes:2 * frame_bytes]))
for count in range(2, 16):
target_client.stdin.write(b"d")
target_client.stdin.flush()
wait_frames(count)
consumer.wait(timeout=7)
self.assertEqual(consumer.returncode, 0)
monitor = raw.read_bytes()
self.assertEqual(len(monitor), frame_bytes * 15)
probe_x = round((cover["x"] + cover["width"] / 2) * scale)
probe_y = round((cover["y"] + cover["height"] / 4) * scale)
probe = (probe_y * ready["width"] + probe_x) * 4
for index in (0, 14):
start = index * frame_bytes + probe
self.assertNotEqual(monitor[start:start + 3], bytes([0, 255, 0]),
"private occluder leaked into the filtered capture")
for index in range(1, 15):
self.assertNotEqual(monitor[(index - 1) * frame_bytes:index * frame_bytes], monitor[index * frame_bytes:(index + 1) * frame_bytes],
"monitor stream reused stale pixels after source damage")
worker.stdin.close()
self.assertEqual(worker.wait(timeout=7), 0)
finally: finally:
for process in reversed(processes): for process in reversed(processes):
if process.poll() is None: if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM) os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=5) process.wait(timeout=5)
if process.stdin:
process.stdin.close()
if process.stdout and process.stdout is not log:
process.stdout.close()
log.close() log.close()
@@ -0,0 +1,159 @@
"""Keep native backing pixels in window streams on a fractionally scaled host.
FERESE_TEST_WINDOW_CAPTURE_SCALE=1 python3 scripts/tests/test_window_capture_scale_isolated.py
Requires a Wayland host, PipeWire, GStreamer, wayland-scanner, cc, and Pillow.
"""
import json
import os
from pathlib import Path
import selectors
import signal
import subprocess
import tempfile
import time
import unittest
from session_socket import ipc_environment, ipc_socket
REPO = Path(__file__).resolve().parents[2]
COMPOSITOR = Path(os.environ.get("FERESE_TEST_BINARY", REPO / "target/release/ferese"))
CTL = Path(os.environ.get("FERESE_TEST_CTL", REPO / "target/release/feresectl"))
PORTAL = Path(os.environ.get("FERESE_TEST_PORTAL", REPO / "target/release/xdg-desktop-portal-ferese"))
@unittest.skipUnless(os.environ.get("FERESE_TEST_WINDOW_CAPTURE_SCALE") == "1", "opt-in capture test")
class WindowCaptureScale(unittest.TestCase):
def test_backing_pixels_survive_fractional_output_scale(self):
children = []
with tempfile.TemporaryDirectory(prefix="ferese-capture-scale-") as temporary:
root = Path(temporary)
protocols = Path(subprocess.check_output(
["pkg-config", "--variable=pkgdatadir", "wayland-protocols"], text=True).strip())
sources = []
for protocol, relative in [("xdg-shell", "stable/xdg-shell/xdg-shell.xml"),
("viewporter", "stable/viewporter/viewporter.xml")]:
source = root / f"{protocol}-protocol.c"
subprocess.run(["wayland-scanner", "client-header", str(protocols / relative),
str(root / f"{protocol}-client-protocol.h")], check=True)
subprocess.run(["wayland-scanner", "private-code", str(protocols / relative),
str(source)], check=True)
sources.append(str(source))
flags = subprocess.check_output(
["pkg-config", "--cflags", "--libs", "wayland-client"], text=True).split()
subprocess.run(["cc", "-Wall", "-Wextra", "-Werror", "-I", str(root),
str(REPO / "scripts/tests/fixtures/window-capture-scale.c"),
*sources, "-o", str(root / "client"), *flags], check=True)
runtime = root / "runtime"
runtime.mkdir(mode=0o700)
config = root / "config/ferese"
config.mkdir(parents=True)
(config / "config.kdl").write_text(
'animations { reduced-motion #true; }\n'
'window-rule app-id="ferese.test.capture-scale" floating=#true\n')
host_runtime = Path(os.environ["XDG_RUNTIME_DIR"])
display = Path(os.environ["WAYLAND_DISPLAY"])
if not display.is_absolute():
display = host_runtime / display
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), WAYLAND_DISPLAY=str(display),
XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"),
XDG_DATA_HOME=str(root / "data"), FERESE_ENABLE_SCREENCOPY="1",
PIPEWIRE_REMOTE=str(host_runtime / "pipewire-0"))
for key in ("WAYLAND_SOCKET", "FERESE_SHELL_CONTROL_SOCKET", "DISPLAY", "XAUTHORITY"):
env.pop(key, None)
log_path = root / "session.log"
with log_path.open("w") as log:
def launch(command, **kwargs):
child = subprocess.Popen(command, env=ipc_environment(env), stderr=log,
stdout=kwargs.pop("stdout", log),
start_new_session=True, **kwargs)
children.append(child)
return child
def wait_for(check):
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
result = check()
if result:
return result
if compositor.poll() is not None:
self.fail(log_path.read_text())
time.sleep(.03)
self.fail("Timed out:\n" + log_path.read_text())
def call(*args):
return json.loads(subprocess.check_output(
[str(CTL), "-j", *args], env=ipc_environment(env), timeout=5))
try:
compositor = launch([str(COMPOSITOR), "--backend=nested"])
wait_for(lambda: ipc_socket(runtime).is_socket())
env["WAYLAND_DISPLAY"] = str(wait_for(
lambda: next((p for p in runtime.glob("wayland-*") if p.is_socket()), None)))
scale = call("outputs")[0]["scale"]
print(f"Isolated capture output scale: {scale}", flush=True)
self.assertTrue(1 < scale < 2,
"This regression needs a fractional host scale between 1 and 2; "
"an integer 2x host masks the original downsampling bug")
for mode, density in [("integer", 2), ("viewport", 2),
("fractional", 1.75), ("cropped", 1),
("subsurface", 2), ("subsurface-outside", 1)]:
with self.subTest(mode=mode, output_scale=scale):
client = launch([str(root / "client"), mode], stdin=subprocess.PIPE)
window = wait_for(lambda: next((w for w in call("windows")
if w["title"] == mode and w["mapped"]), None))
worker = launch([str(PORTAL), "--stream-window", str(window["id"]), "hidden"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE)
with selectors.DefaultSelector() as selector:
selector.register(worker.stdout, selectors.EVENT_READ)
self.assertTrue(selector.select(10), log_path.read_text())
ready = json.loads(worker.stdout.readline())
expected = (round(640 * max(scale, density)),
round(480 * max(scale, density)))
self.assertEqual((ready["width"], ready["height"]), expected,
"window sharing discarded native backing pixels")
raw = root / f"{mode}.bgrx"
consumer = launch(["gst-launch-1.0", "-q", "pipewiresrc",
f'path={ready["node"]}', "num-buffers=3", "!",
"video/x-raw,format=BGRx", "!", "filesink", "buffer-mode=unbuffered", f"location={raw}"])
frame_bytes = expected[0] * expected[1] * 4
wait_for(lambda: raw.exists() and raw.stat().st_size >= frame_bytes)
# A static source waits for damage. Generate each next
# commit explicitly instead of relying on duplicate frames.
for count in (2, 3):
client.stdin.write(b"d")
client.stdin.flush()
wait_for(lambda: raw.stat().st_size >= count * frame_bytes)
consumer.wait(timeout=5)
self.assertEqual(consumer.returncode, 0)
width, height = expected
pixels = raw.read_bytes()
self.assertEqual(len(pixels), width * height * 4 * 3)
if mode == "subsurface-outside":
self.assertFalse(any(pixels[0::4]) or any(pixels[1::4])
or any(pixels[2::4]),
"content outside the window was captured")
elif density >= scale:
for index in (0, 2):
start = (index * width * height + height // 2 * width) * 4
row = pixels[start:start + width * 4]
self.assertEqual(row, bytes((0, 0, 0, 255, 255, 255, 255, 255)) * (width // 2),
"native one-pixel stripes were filtered")
worker.stdin.close()
worker.wait(timeout=5)
worker.stdout.close()
client.terminate()
client.wait(timeout=5)
client.stdin.close()
wait_for(lambda: not any(w["id"] == window["id"] for w in call("windows")))
finally:
for child in reversed(children):
if child.poll() is None:
os.killpg(child.pid, signal.SIGTERM)
child.wait(timeout=5)
for pipe in (child.stdin, child.stdout, child.stderr):
if pipe:
pipe.close()
if __name__ == "__main__":
unittest.main()