Add a second capture backend for compositors without wlr-screencopy (KWin, GNOME, etc.) using the xdg-desktop-portal ScreenCast interface and PipeWire DMA-BUF streaming. New files: - src/backend_detect.rs: auto-detect wlr-screencopy vs portal backend - src/cap_portal.rs: Portal session setup + PipeWire DMA-BUF thread - src/state_portal.rs: StatePortal encoder pipeline (DMA-BUF → VAAPI) Changes: - Cargo.toml: add ashpd 0.13, tokio 1, pipewire 0.9, libspa 0.9, crossbeam-channel 0.5 - src/args.rs: add --backend CLI flag - src/avhw.rs: extract create_encoder() from inline State code - src/main.rs: route to portal or wlr-screencopy based on backend - src/state.rs: fix params.destroy() on dup failure, cleanup in_flight_surface on copy fail, use create_encoder() - tests/integration_test.rs: add --backend flag tests
410 lines
11 KiB
Rust
410 lines
11 KiB
Rust
/// Coordinate transformation module for Wayland output transforms.
|
||
///
|
||
/// Handles the 8 `wl_output` transform variants (rotation + reflection)
|
||
/// and ROI clipping for screen capture.
|
||
///
|
||
/// Wayland output transform enum, matching `wl_output::Transform`.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum Transform {
|
||
Normal,
|
||
Normal90,
|
||
Normal180,
|
||
Normal270,
|
||
Flipped,
|
||
Flipped90,
|
||
Flipped180,
|
||
Flipped270,
|
||
}
|
||
|
||
/// Axis-aligned rectangle in integer coordinates.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct Rect {
|
||
pub x: i32,
|
||
pub y: i32,
|
||
pub w: i32,
|
||
pub h: i32,
|
||
}
|
||
|
||
/// Returns the 2×2 basis matrix (a, b, c, d) for the given transform.
|
||
///
|
||
/// The matrix represents the affine mapping from screen coordinates to
|
||
/// frame coordinates:
|
||
///
|
||
/// ```text
|
||
/// [new_x] [a b] [x]
|
||
/// [new_y] = [c d] [y]
|
||
/// ```
|
||
pub fn transform_basis(transform: Transform) -> (i32, i32, i32, i32) {
|
||
match transform {
|
||
Transform::Normal => (1, 0, 0, 1),
|
||
Transform::Normal90 => (0, 1, -1, 0),
|
||
Transform::Normal180 => (-1, 0, 0, -1),
|
||
Transform::Normal270 => (0, -1, 1, 0),
|
||
Transform::Flipped => (-1, 0, 0, 1),
|
||
Transform::Flipped90 => (0, 1, 1, 0),
|
||
Transform::Flipped180 => (1, 0, 0, -1),
|
||
Transform::Flipped270 => (0, -1, -1, 0),
|
||
}
|
||
}
|
||
|
||
/// Transform a rectangle from screen space to frame space.
|
||
///
|
||
/// Applies the 2×2 basis matrix and computes offsets so the result
|
||
/// fits within the frame dimensions `(frame_w, frame_h)`.
|
||
///
|
||
/// ```text
|
||
/// new_x = a * x + b * y + offset_x
|
||
/// new_y = c * x + d * y + offset_y
|
||
/// ```
|
||
pub fn screen_to_frame(transform: Transform, rect: Rect, frame_w: i32, frame_h: i32) -> Rect {
|
||
let (a, b, c, d) = transform_basis(transform);
|
||
|
||
// Compute the offset so that the transformed origin maps correctly.
|
||
// For transforms with negative components, we need to shift by the
|
||
// frame dimension to keep coordinates in [0, frame_w) × [0, frame_h).
|
||
let offset_x = if a + b < 0 { frame_w } else { 0 };
|
||
let offset_y = if c + d < 0 { frame_h } else { 0 };
|
||
|
||
let new_x = a * rect.x + b * rect.y + offset_x;
|
||
let new_y = c * rect.x + d * rect.y + offset_y;
|
||
let new_w = a * rect.w + b * rect.h;
|
||
let new_h = c * rect.w + d * rect.h;
|
||
|
||
Rect {
|
||
x: new_x,
|
||
y: new_y,
|
||
w: new_w.abs(),
|
||
h: new_h.abs(),
|
||
}
|
||
}
|
||
|
||
/// Swap width and height for 90° or 270° rotations.
|
||
///
|
||
/// After a quarter-turn rotation the output dimensions are transposed
|
||
/// relative to the input. This helper returns `(h, w)` for those cases
|
||
/// and `(w, h)` unchanged otherwise.
|
||
pub fn transpose_if_transform_transposed(transform: Transform, w: i32, h: i32) -> (i32, i32) {
|
||
match transform {
|
||
Transform::Normal90
|
||
| Transform::Normal270
|
||
| Transform::Flipped90
|
||
| Transform::Flipped270 => (h, w),
|
||
_ => (w, h),
|
||
}
|
||
}
|
||
|
||
/// Clip a rectangle so it stays inside `(0, 0) .. (bounds_w, bounds_h)`.
|
||
///
|
||
/// The resulting rectangle has non-negative origin and its extent does
|
||
/// not exceed the bounds.
|
||
pub fn fit_inside_bounds(rect: Rect, bounds_w: i32, bounds_h: i32) -> Rect {
|
||
let x = rect.x.clamp(0, bounds_w);
|
||
let y = rect.y.clamp(0, bounds_h);
|
||
let right = (rect.x + rect.w).min(bounds_w);
|
||
let bottom = (rect.y + rect.h).min(bounds_h);
|
||
let w = (right - x).max(0);
|
||
let h = (bottom - y).max(0);
|
||
Rect { x, y, w, h }
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
// ── transform_basis ───────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn basis_normal_is_identity() {
|
||
assert_eq!(transform_basis(Transform::Normal), (1, 0, 0, 1));
|
||
}
|
||
|
||
#[test]
|
||
fn basis_90_cw_rotation() {
|
||
assert_eq!(transform_basis(Transform::Normal90), (0, 1, -1, 0));
|
||
}
|
||
|
||
#[test]
|
||
fn basis_180_rotation() {
|
||
assert_eq!(transform_basis(Transform::Normal180), (-1, 0, 0, -1));
|
||
}
|
||
|
||
#[test]
|
||
fn basis_270_cw_rotation() {
|
||
assert_eq!(transform_basis(Transform::Normal270), (0, -1, 1, 0));
|
||
}
|
||
|
||
#[test]
|
||
fn basis_flipped_horizontal() {
|
||
assert_eq!(transform_basis(Transform::Flipped), (-1, 0, 0, 1));
|
||
}
|
||
|
||
#[test]
|
||
fn basis_flipped_90() {
|
||
assert_eq!(transform_basis(Transform::Flipped90), (0, 1, 1, 0));
|
||
}
|
||
|
||
#[test]
|
||
fn basis_flipped_180() {
|
||
assert_eq!(transform_basis(Transform::Flipped180), (1, 0, 0, -1));
|
||
}
|
||
|
||
#[test]
|
||
fn basis_flipped_270() {
|
||
assert_eq!(transform_basis(Transform::Flipped270), (0, -1, -1, 0));
|
||
}
|
||
|
||
// ── screen_to_frame ───────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn screen_to_frame_identity_unchanged() {
|
||
let rect = Rect {
|
||
x: 10,
|
||
y: 20,
|
||
w: 100,
|
||
h: 50,
|
||
};
|
||
let result = screen_to_frame(Transform::Normal, rect, 1920, 1080);
|
||
assert_eq!(
|
||
result,
|
||
Rect {
|
||
x: 10,
|
||
y: 20,
|
||
w: 100,
|
||
h: 50
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn screen_to_frame_90_rotates_origin() {
|
||
// 90° CW: top-left (0,0) in screen should map to bottom-left in frame
|
||
let rect = Rect {
|
||
x: 0,
|
||
y: 0,
|
||
w: 100,
|
||
h: 50,
|
||
};
|
||
let result = screen_to_frame(Transform::Normal90, rect, 1080, 1920);
|
||
// a=0,b=1,c=-1,d=0 => offset_x=0, offset_y=1920 (c+d=-1<0)
|
||
// new_x = 0*0 + 1*0 + 0 = 0
|
||
// new_y = -1*0 + 0*0 + 1920 = 1920
|
||
assert_eq!(result.x, 0);
|
||
assert_eq!(result.y, 1920);
|
||
// w' = 0*100 + 1*50 = 50, h' = -1*100 + 0*50 = -100 -> abs=100
|
||
assert_eq!(result.w, 50);
|
||
assert_eq!(result.h, 100);
|
||
}
|
||
|
||
#[test]
|
||
fn screen_to_frame_180_rotates() {
|
||
let rect = Rect {
|
||
x: 100,
|
||
y: 200,
|
||
w: 300,
|
||
h: 400,
|
||
};
|
||
let result = screen_to_frame(Transform::Normal180, rect, 1920, 1080);
|
||
// a=-1,b=0,c=0,d=-1, offset_x=1920, offset_y=1080
|
||
assert_eq!(result.x, -100 + 1920);
|
||
assert_eq!(result.y, -200 + 1080);
|
||
assert_eq!(result.w, 300);
|
||
assert_eq!(result.h, 400);
|
||
}
|
||
|
||
#[test]
|
||
fn screen_to_frame_flipped_horizontal() {
|
||
let rect = Rect {
|
||
x: 50,
|
||
y: 30,
|
||
w: 200,
|
||
h: 100,
|
||
};
|
||
let result = screen_to_frame(Transform::Flipped, rect, 1920, 1080);
|
||
// a=-1,b=0,c=0,d=1, offset_x=1920, offset_y=0
|
||
assert_eq!(result.x, -50 + 1920);
|
||
assert_eq!(result.y, 30);
|
||
assert_eq!(result.w, 200);
|
||
assert_eq!(result.h, 100);
|
||
}
|
||
|
||
// ── transpose_if_transform_transposed ─────────────────────────
|
||
|
||
#[test]
|
||
fn transpose_normal_no_swap() {
|
||
assert_eq!(
|
||
transpose_if_transform_transposed(Transform::Normal, 1920, 1080),
|
||
(1920, 1080)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn transpose_90_swaps() {
|
||
assert_eq!(
|
||
transpose_if_transform_transposed(Transform::Normal90, 1920, 1080),
|
||
(1080, 1920)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn transpose_180_no_swap() {
|
||
assert_eq!(
|
||
transpose_if_transform_transposed(Transform::Normal180, 1920, 1080),
|
||
(1920, 1080)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn transpose_270_swaps() {
|
||
assert_eq!(
|
||
transpose_if_transform_transposed(Transform::Normal270, 1920, 1080),
|
||
(1080, 1920)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn transpose_flipped_no_swap() {
|
||
assert_eq!(
|
||
transpose_if_transform_transposed(Transform::Flipped, 1920, 1080),
|
||
(1920, 1080)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn transpose_flipped90_swaps() {
|
||
assert_eq!(
|
||
transpose_if_transform_transposed(Transform::Flipped90, 1920, 1080),
|
||
(1080, 1920)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn transpose_flipped180_no_swap() {
|
||
assert_eq!(
|
||
transpose_if_transform_transposed(Transform::Flipped180, 1920, 1080),
|
||
(1920, 1080)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn transpose_flipped270_swaps() {
|
||
assert_eq!(
|
||
transpose_if_transform_transposed(Transform::Flipped270, 1920, 1080),
|
||
(1080, 1920)
|
||
);
|
||
}
|
||
|
||
// ── fit_inside_bounds ─────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn fit_inside_already_fits() {
|
||
let rect = Rect {
|
||
x: 10,
|
||
y: 20,
|
||
w: 100,
|
||
h: 50,
|
||
};
|
||
let result = fit_inside_bounds(rect, 1920, 1080);
|
||
assert_eq!(result, rect);
|
||
}
|
||
|
||
#[test]
|
||
fn fit_inside_clips_right_and_bottom() {
|
||
let rect = Rect {
|
||
x: 1800,
|
||
y: 1000,
|
||
w: 200,
|
||
h: 200,
|
||
};
|
||
let result = fit_inside_bounds(rect, 1920, 1080);
|
||
assert_eq!(
|
||
result,
|
||
Rect {
|
||
x: 1800,
|
||
y: 1000,
|
||
w: 120,
|
||
h: 80
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fit_inside_clips_negative_origin() {
|
||
let rect = Rect {
|
||
x: -50,
|
||
y: -30,
|
||
w: 200,
|
||
h: 200,
|
||
};
|
||
let result = fit_inside_bounds(rect, 1920, 1080);
|
||
assert_eq!(
|
||
result,
|
||
Rect {
|
||
x: 0,
|
||
y: 0,
|
||
w: 150,
|
||
h: 170
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fit_inside_completely_out_of_bounds() {
|
||
let rect = Rect {
|
||
x: 2000,
|
||
y: 2000,
|
||
w: 100,
|
||
h: 100,
|
||
};
|
||
let result = fit_inside_bounds(rect, 1920, 1080);
|
||
assert_eq!(
|
||
result,
|
||
Rect {
|
||
x: 1920,
|
||
y: 1080,
|
||
w: 0,
|
||
h: 0
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fit_inside_zero_size_rect() {
|
||
let rect = Rect {
|
||
x: 100,
|
||
y: 100,
|
||
w: 0,
|
||
h: 0,
|
||
};
|
||
let result = fit_inside_bounds(rect, 1920, 1080);
|
||
assert_eq!(
|
||
result,
|
||
Rect {
|
||
x: 100,
|
||
y: 100,
|
||
w: 0,
|
||
h: 0
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fit_inside_zero_bounds() {
|
||
let rect = Rect {
|
||
x: 0,
|
||
y: 0,
|
||
w: 100,
|
||
h: 100,
|
||
};
|
||
let result = fit_inside_bounds(rect, 0, 0);
|
||
assert_eq!(
|
||
result,
|
||
Rect {
|
||
x: 0,
|
||
y: 0,
|
||
w: 0,
|
||
h: 0
|
||
}
|
||
);
|
||
}
|
||
}
|