diff --git a/compositor/src/handlers/window_capture.rs b/compositor/src/handlers/window_capture.rs index 9397cc6..b9b94be 100644 --- a/compositor/src/handlers/window_capture.rs +++ b/compositor/src/handlers/window_capture.rs @@ -27,9 +27,34 @@ 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) + let width = view.src.size.w * f64::from(buffer_scale); + let height = view.src.size.h * f64::from(buffer_scale); + let logical_width = f64::from(view.dst.w); + let logical_height = f64::from(view.dst.h); + let matches_native = |scale: f64| { + (logical_width * scale).round() == width && (logical_height * scale).round() == height + }; + // Fractional clients round their backing dimensions independently. Taking + // the larger density can add a pixel on the other axis and filter every + // row or column, even though the client already rendered at this scale. + if matches_native(output_scale) { + return output_scale; + } + let scale = output_scale + .max(width / logical_width) + .max(height / logical_height); + if matches_native(scale) { + return scale; + } + // Prefer a common density whose rounded dimensions preserve both axes. + // Stay strictly below the upper rounding boundary, including FP error. + let upper = ((width + 0.5) / logical_width).min((height + 0.5) / logical_height); + let adjusted = upper * (1.0 - 8.0 * f64::EPSILON); + if adjusted >= output_scale && matches_native(adjusted) { + adjusted + } else { + scale + } } #[derive(Debug, Default)] @@ -621,6 +646,23 @@ pub(crate) mod tests { assert_eq!(capture_scale(1.75, 1, cropped), 1.75); } + #[test] + fn sharing_preserves_independently_rounded_fractional_buffers() { + for (width, height) in [(3819., 2091.), (3819., 2092.)] { + let view = SurfaceView { + src: Rectangle::from_size((width, height).into()), + dst: (2182, 1195).into(), + offset: (0, 0).into(), + }; + for output_scale in [1.25, 1.5, 1.75] { + let scale = capture_scale(output_scale, 1, view); + assert!(scale >= output_scale); + assert_eq!((2182. * scale).round(), width); + assert_eq!((1195. * scale).round(), height); + } + } + } + /// Exercise both asynchronous publication boundaries using real resources. pub(crate) fn check_deferred_snapshot( state: &Ferese, diff --git a/docs/screen-sharing.md b/docs/screen-sharing.md index 51469da..0f33285 100644 --- a/docs/screen-sharing.md +++ b/docs/screen-sharing.md @@ -35,6 +35,9 @@ the captured surface tree when it exceeds the display scale. For example, a 2× application buffer stays at 2× in the stream on a 1.75× display, avoiding a fractional downsample before the recording app scales it. The video size follows window resizes and backing buffer density changes; closing the window ends sharing. +At fractional scales, clients can round the two backing dimensions separately. +Capture preserves those pixel dimensions rather than adding a row or column +by applying the larger axis density to both axes, which would soften text. The picker follows your shell colors, font, corners, opacity, and blur. diff --git a/scripts/tests/fixtures/window-capture-scale.c b/scripts/tests/fixtures/window-capture-scale.c index 0e3df0f..f29a33b 100644 --- a/scripts/tests/fixtures/window-capture-scale.c +++ b/scripts/tests/fixtures/window-capture-scale.c @@ -18,6 +18,8 @@ static struct xdg_wm_base *wm; static struct wp_viewporter *viewporter; static struct wl_surface *surface; static struct wl_buffer *buffer; +static int logical_width = 640; +static int logical_height = 480; static void ping(void *data, struct xdg_wm_base *base, uint32_t serial) { (void)data; @@ -49,7 +51,7 @@ 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); + xdg_surface_set_window_geometry(xdg, 0, 0, logical_width, logical_height); wl_surface_attach(surface, buffer, 0, 0); wl_surface_damage_buffer(surface, 0, 0, INT32_MAX, INT32_MAX); wl_surface_commit(surface); @@ -73,9 +75,15 @@ int main(int argc, char **argv) { 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 rounded = !strcmp(argv[1], "fractional-rounded"); + int ceiled = !strcmp(argv[1], "fractional-ceiled"); int cropped = !strcmp(argv[1], "cropped"); - int width = fractional ? 1120 : 1280; - int height = fractional ? 840 : 960; + if (rounded || ceiled) { + logical_width = 2182; + logical_height = 1195; + } + int width = rounded || ceiled ? 3819 : fractional ? 1120 : 1280; + int height = rounded ? 2091 : ceiled ? 2092 : 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; @@ -83,7 +91,8 @@ int main(int argc, char **argv) { 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; + pixels[y * width + x] = (x + ((rounded || ceiled) ? y : 0)) % 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); @@ -116,7 +125,7 @@ int main(int argc, char **argv) { 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); + wp_viewport_set_destination(viewport, logical_width, logical_height); 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)); diff --git a/scripts/tests/test_window_capture_scale_isolated.py b/scripts/tests/test_window_capture_scale_isolated.py index 1ff9808..b5e7ad0 100644 --- a/scripts/tests/test_window_capture_scale_isolated.py +++ b/scripts/tests/test_window_capture_scale_isolated.py @@ -96,7 +96,9 @@ class WindowCaptureScale(unittest.TestCase): "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)]: + ("subsurface", 2), ("subsurface-outside", 1), + ("fractional-rounded", 1.75), + ("fractional-ceiled", 1.75)]: 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") @@ -109,6 +111,10 @@ class WindowCaptureScale(unittest.TestCase): ready = json.loads(worker.stdout.readline()) expected = (round(640 * max(scale, density)), round(480 * max(scale, density))) + if mode == "fractional-rounded": + expected = (3819, 2091) + elif mode == "fractional-ceiled": + expected = (3819, 2092) self.assertEqual((ready["width"], ready["height"]), expected, "window sharing discarded native backing pixels") raw = root / f"{mode}.bgrx" @@ -134,6 +140,17 @@ class WindowCaptureScale(unittest.TestCase): "content outside the window was captured") elif density >= scale: for index in (0, 2): + if mode.startswith("fractional-"): + for y in (0, height // 2, height - 1): + start = (index * width * height + y * width) * 4 + row = pixels[start:start + width * 4] + expected_row = b"".join( + bytes((255, 255, 255, 255)) if (x + y) % 2 + else bytes((0, 0, 0, 255)) + for x in range(width)) + self.assertEqual(row, expected_row, + "rounded native pixels were filtered") + continue 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),