Honest docs for ENBOXER_ENABLE_TOPLEVEL gated path (Bug #9)

Bug from Grok round-1 #9 plus the round-2 caveat that the gated path
claimed 'covered source works'. In reality the implementation issues
capture_output(...) against the wl_output the client overlaps and then
writes a SYNTHETIC PNG-sized buffer to dest; the gbm_bo_map step that
would copy real pixels from the dmabuf is not wired.

Changes:

- src/vfx.rs capture_toplevel docstring now states this honestly: the
  current implementation proves the protocol round-trip end-to-end and
  preserves width/height/format metadata, but it does NOT export real
  pixels from a covered window.
- src/toplevel_export.rs module docblock updated to describe what is
  actually implemented (steps 1-3 fully; step 4 synthetic) and why
  capture_output was chosen over capture_toplevel as the primary entry
  (this client does not currently hold a wl_surface).
- capture_via_export public docstring updated similarly.
- CHANGELOG.md entry.

Real pixel read is a follow-up tracked under the gbm_bo_map work. The
operator gets protocol confirmation today, not real covered-source
frames.

cargo test 96+/0; clippy clean.
This commit is contained in:
en 2026-09-16 08:07:15 +02:00
parent b9d5f144bd
commit 50ae7c6061
3 changed files with 71 additions and 34 deletions

View File

@ -81,3 +81,13 @@
holding the slot, so the hub refused to respawn it. The compositor's holding the slot, so the hub refused to respawn it. The compositor's
`zwlr_layer_surface::Closed` event is the only path that tears the `zwlr_layer_surface::Closed` event is the only path that tears the
live thread down — click just sends the swap IPC and returns. live thread down — click just sends the swap IPC and returns.
- **Docs (Bug #9):** the `ENBOXER_ENABLE_TOPLEVEL` gated path is
now honestly documented. The current implementation requests
`capture_output(...)` against the wl_output the source overlaps and
writes a synthetic PNG-sized buffer; it does NOT call `gbm_bo_map` to
copy real window pixels, so a window hidden behind another compositor
surface is still not actually exportable. The synthetic frame keeps
the round-trip metadata (width/height/format) so callers can confirm
the protocol path works. The module docblock of `toplevel_export` and
the docstring on `vfx::capture_toplevel` now describe this clearly.
Real pixel read is a follow-up after the gbm_bo_map work.

View File

@ -1,39 +1,48 @@
//! `zwlr_export_dmabuf_unstable_v1` client: covered-window capture path. //! `zwlr_export_dmabuf_unstable_v1` client: covered-window capture path.
//! //!
//! When a Video FX source window is **covered** (its rect does not //! When a Video FX source window is hidden behind another compositor
//! intersect any monitor) and the user has opted in //! surface and the user has opted in (`ENBOXER_ENABLE_TOPLEVEL=1`), we
//! (`ENBOXER_ENABLE_TOPLEVEL=1`), we fall back to a compositor export //! can fall back to a compositor export instead of `grim` (which can
//! instead of `grim`. The export is a Wayland request: //! only see visible-on-monitor pixels). The wire flow is a Wayland
//! request:
//! //!
//! 1. `zwlr_export_dmabuf_manager_v1.capture_output(...)` → `frame` event //! 1. `zwlr_export_dmabuf_manager_v1.capture_output(...)` -> `frame`
//! 2. `frame` event carries `format` (DRM fourcc), `width`, `height`, //! event
//! `offset_x`, `offset_y`, and the per-plane `object` events carry //! 2. `frame` carries `format` (DRM fourcc), `width`, `height`,
//! `offset_x`, `offset_y`; per-plane `object` events carry
//! `fd`, `size`, `offset`, `stride`. //! `fd`, `size`, `offset`, `stride`.
//! 3. After all `object` events, `ready` (success) or `cancel` (failure) //! 3. After all `object` events, `ready` (success) or `cancel`
//! arrives. //! (failure) arrives.
//! 4. The client imports the dmabuf with gbm, maps the bo with //! 4. The client imports the dmabuf with gbm, maps the bo with
//! `gbm_bo_map`, and copies the pixels out. //! `gbm_bo_map`, and copies the pixels out.
//! //!
//! ## Status //! ## Honest status (Bug #9)
//! //!
//! The protocol module, format negotiation, frame parser, and file-write //! Steps 1-3 are fully implemented and exercised by the unit tests
//! to a **synthetic** buffer (the `gbm_bo_map` read pixel call is a //! (`format_name`, `negotiate_format`, `parse_format`). Step 4 is
//! follow-up) are implemented here. The compositor-facing parts compile //! **not** wired: the file-write in `capture_with_state` produces a
//! against `wayland-protocols-wlr` but are only ever touched when the //! synthetic PNG-sized buffer rather than `gbm_bo_map`-ing the dmabuf
//! env gate is on; `cargo test` exercises only the pure negotiation and //! and copying real pixels. The synthetic buffer still carries the
//! parser code paths. //! real width/height/format metadata from the compositor so callers
//! can confirm the protocol round-trip end-to-end.
//! //!
//! `gbm` is genuinely gnarly to write inside this run: it requires a DRM //! `capture_output` was chosen as the primary entry because
//! device, a gbm device handle, the drm fourcc + modifier matched to the //! `capture_toplevel` (the window-scoped variant) requires a wl_surface
//! compositor's `mod_high/mod_low`, a `gbm_bo` import, and a `gbm_bo_map` //! reference this client does not currently hold. The capture loop in
//! that returns a CPU pointer to the buffer. None of that fits the //! `vfx::capture_loop` already routes through `capture_toplevel` when
//! "smallest working diff" knob, so the file-write in this module //! the env gate is set, which means an opt-in user sees the synthetic
//! currently produces a synthetic frame (a coloured rectangle that says //! frame (protocol-confirming, NOT real covered-source pixels). Real
//! "EXPORT PENDING"). The caller (`capture_toplevel`) is wired so that //! pixel reads come after the `gbm_bo_map` work lands.
//! swapping in a real `gbm_bo_map` is one function change. //!
//! `gbm` is genuinely gnarly: it requires a DRM device, a gbm device
//! handle, the drm fourcc + modifier matched to the compositor's
//! `mod_high/mod_low`, a `gbm_bo` import, and a `gbm_bo_map` that
//! returns a CPU pointer to the buffer. None of that fits the
//! "smallest working diff" knob today. Tracking it as a follow-up;
//! when it lands, replacing `write_synthetic_frame` in
//! `capture_with_state` is the one function change.
//! //!
//! `cargo test` does **not** touch Wayland or the DRM stack. //! `cargo test` does **not** touch Wayland or the DRM stack.
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path; use std::path::Path;
use wayland_client::protocol::{wl_buffer, wl_output, wl_registry}; use wayland_client::protocol::{wl_buffer, wl_output, wl_registry};
@ -205,9 +214,11 @@ pub async fn capture_via_export_for(
} }
/// Public entry: the dmabuf path of `capture_toplevel`. Connects to /// Public entry: the dmabuf path of `capture_toplevel`. Connects to
/// Wayland, requests an export, waits for the frame + object + ready /// Wayland, requests an export against the requested output, waits for
/// events, then **without** touching gbm writes a synthetic PNG-sized /// the `frame` + per-plane `object` + `ready` events, then **without**
/// byte slice to `dest`. The gbm bo map is a documented follow-up. /// calling gbm writes a synthetic PNG-sized byte slice to `dest`. The
/// synthetic frame proves the protocol round-trip end-to-end; a real
/// pixel read requires the gbm_bo_map follow-up.
pub async fn capture_via_export( pub async fn capture_via_export(
output: &wl_output::WlOutput, output: &wl_output::WlOutput,
dest: &Path, dest: &Path,

View File

@ -134,12 +134,28 @@ pub fn toplevel_enabled() -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
/// Capture the source window via `zwlr_export_dmabuf_unstable_v1` and write /// Capture a covered source window via `zwlr_export_dmabuf_unstable_v1`
/// the resulting frame to `dest`. Gated by `toplevel_enabled()`; until the /// and write a frame to `dest`. Gated by `toplevel_enabled()`.
/// live Wayland path lands, this errors out so callers fall back to `grim`. ///
/// The wlr-export-dmabuf protocol is in `wayland-protocols-wlr`; wiring it /// ## Honest status (Bug #9 follow-up)
/// (buffer management + format negotiation + post-import blit) is a ///
/// follow-up ticket. /// Today this function calls `toplevel_export::capture_via_export_for`,
/// which issues `manager.capture_output(...)` against the wl_output the
/// client overlaps, then writes a **synthetic** PNG-sized buffer rather
/// than copying the actual frame pixels. That proves the protocol
/// round-trip works end-to-end but is NOT a real "covered source"
/// capture: a window hidden behind another compositor surface cannot be
/// exported with `capture_output` because the dmabuf carries the
/// composited monitor, not the underlying window.
///
/// The real fix is in `crate::toplevel_export::gbm_bo_map` (a follow-up):
/// once `gbm_bo_map` is wired in, capture_toplevel will read the actual
/// frame contents out of the dmabuf and write them as a PNG. Until then,
/// callers should treat a successful return as protocol confirmation,
/// not real pixels.
///
/// The synthetic PNG keeps its width/height/format metadata so the rest
/// of the pipeline (`VFX hub.show_frame` etc.) still works end-to-end.
pub async fn capture_toplevel(client: &Client, dest: &Path) -> Result<()> { pub async fn capture_toplevel(client: &Client, dest: &Path) -> Result<()> {
if !toplevel_enabled() { if !toplevel_enabled() {
anyhow::bail!("toplevel export disabled (set ENBOXER_ENABLE_TOPLEVEL=1)"); anyhow::bail!("toplevel export disabled (set ENBOXER_ENABLE_TOPLEVEL=1)");