diff --git a/crates/compositor/src/pipeline_linux.rs b/crates/compositor/src/pipeline_linux.rs index edc9ea623d..2a7d843d7f 100644 --- a/crates/compositor/src/pipeline_linux.rs +++ b/crates/compositor/src/pipeline_linux.rs @@ -1330,39 +1330,29 @@ impl VaapiEncoder { /// manque : l'appelant retombe alors sur l'encodeur software. pub unsafe fn open(w: i32, h: i32, fps: i32, bit_rate: i64) -> Option { use crate::ffi::*; - let mut me = VaapiEncoder { - ctx: ptr::null_mut(), - drm_device: ptr::null_mut(), - va_device: ptr::null_mut(), - drm_frames: ptr::null_mut(), - va_frames: ptr::null_mut(), - graph: ptr::null_mut(), - vpp_in: ptr::null_mut(), - vpp_out: ptr::null_mut(), - w, - h, - }; // LE DEVICE DRM D'ABORD, PUIS VAAPI DERIVE DE LUI. L'ordre inverse // (VAAPI ouvert seul) rend ENOSYS sur radeonsi : le mapping veut les deux - // cotes d'un meme device. - let node = std::ffi::CString::new("/dev/dri/renderD128").ok()?; - if av_hwdevice_ctx_create( - &mut me.drm_device, - AVHWDeviceType::AV_HWDEVICE_TYPE_DRM, - node.as_ptr(), - ptr::null_mut(), - 0, - ) < 0 - { - return None; + // cotes d'un meme device. Sur les portables hybrides, la carte avec VAAPI + // peut etre sur renderD128 ou renderD129 : on honore OPENSCREEN_LINUX_RENDER_NODE + // puis on sonde les noeuds DRM disponibles. + let mut candidates: Vec = Vec::new(); + if let Ok(explicit) = std::env::var("OPENSCREEN_LINUX_RENDER_NODE") { + if !explicit.is_empty() { + candidates.push(explicit); + } } - if av_hwdevice_ctx_create_derived( - &mut me.va_device, - AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI, - me.drm_device, - 0, - ) < 0 - { + for idx in 128..=135 { + let p = format!("/dev/dri/renderD{idx}"); + if Path::new(&p).exists() && !candidates.contains(&p) { + candidates.push(p); + } + } + if candidates.is_empty() { + candidates.push("/dev/dri/renderD128".to_owned()); + } + let name = std::ffi::CString::new("h264_vaapi").ok()?; + let enc = avcodec_find_encoder_by_name(name.as_ptr()); + if enc.is_null() { return None; } // `initial_pool_size = 0` sur LES DEUX contextes : ils ne font @@ -1382,70 +1372,107 @@ impl VaapiEncoder { (*c).height = h; (*c).initial_pool_size = 0; if av_hwframe_ctx_init(frames) < 0 { + let mut f = frames; + av_buffer_unref(&mut f); return ptr::null_mut(); } frames }; - me.drm_frames = mk_frames(me.drm_device, AVPixelFormat::AV_PIX_FMT_DRM_PRIME); - me.va_frames = mk_frames(me.va_device, AVPixelFormat::AV_PIX_FMT_VAAPI); - if me.drm_frames.is_null() || me.va_frames.is_null() { - return None; - } - // Le pilote decide. Une chaine illisible compte comme « pas iHD » : on - // garde alors le chemin direct, celui d'avant la copie. - if va_vendor(me.va_device).is_some_and(|v| needs_vpp_copy(&v)) { - me.build_vpp()?; - } - let name = std::ffi::CString::new("h264_vaapi").ok()?; - let enc = avcodec_find_encoder_by_name(name.as_ptr()); - if enc.is_null() { - return None; - } - me.ctx = avcodec_alloc_context3(enc); - if me.ctx.is_null() { - return None; - } - (*me.ctx).width = w; - (*me.ctx).height = h; - (*me.ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_VAAPI as i32; - (*me.ctx).time_base = AVRational { num: 1, den: fps }; - (*me.ctx).framerate = AVRational { num: fps, den: 1 }; - (*me.ctx).bit_rate = bit_rate; - // MEME INTERVALLE D'IMAGES CLES QUE LE CHEMIN SOFTWARE, et pour la meme - // raison : deux secondes est le compromis choisi pour un fichier de - // sortie, il n'a pas a dependre de l'encodeur qui se trouve disponible. - // - // Sans cette ligne le resultat est correct A 60 FPS ET NULLE PART - // AILLEURS : `h264_vaapi` a un defaut de 120 frames (mesure), qui vaut - // deux secondes a 60 fps par coincidence. A 30 fps ca donnerait quatre - // secondes, a 120 fps une seule. Le defaut de `libopenh264` est -1, ce - // qui est un autre probleme encore (une seule image cle pour tout le - // fichier) traite dans `try_open`. - (*me.ctx).gop_size = (fps * 2).max(1); - // MP4 veut SPS/PPS dans l'extradata, pas repetes devant chaque image - // cle. Le chemin software le pose depuis toujours (`try_open`) ; l'avoir - // oublie ici produisait un fichier qui se lit quand meme, parce que le - // muxer recupere ce qu'il trouve — mais un lecteur qui se fie a - // `codecpar` seul aurait de quoi echouer. - (*me.ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as i32; - // La matrice des frames que `send_dmabuf` pose, celle de `yuv.wgsl`. - tag_export_color(me.ctx, AVColorSpace::AVCOL_SPC_SMPTE170M); - // Avec la copie, les surfaces de SORTIE du VPP, pas celles du mapping : - // c'est elles que l'encodeur recoit. - let out_frames = if me.graph.is_null() { - me.va_frames - } else { - av_buffersink_get_hw_frames_ctx(me.vpp_out) - }; - if out_frames.is_null() { - return None; - } - (*me.ctx).hw_frames_ctx = av_buffer_ref(out_frames); - if avcodec_open2(me.ctx, enc, ptr::null_mut()) < 0 { - return None; + for cand in candidates { + let Ok(node) = std::ffi::CString::new(cand) else { + continue; + }; + let mut me = VaapiEncoder { + ctx: ptr::null_mut(), + drm_device: ptr::null_mut(), + va_device: ptr::null_mut(), + drm_frames: ptr::null_mut(), + va_frames: ptr::null_mut(), + graph: ptr::null_mut(), + vpp_in: ptr::null_mut(), + vpp_out: ptr::null_mut(), + w, + h, + }; + if av_hwdevice_ctx_create( + &mut me.drm_device, + AVHWDeviceType::AV_HWDEVICE_TYPE_DRM, + node.as_ptr(), + ptr::null_mut(), + 0, + ) < 0 + { + continue; + } + if av_hwdevice_ctx_create_derived( + &mut me.va_device, + AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI, + me.drm_device, + 0, + ) < 0 + { + continue; + } + me.drm_frames = mk_frames(me.drm_device, AVPixelFormat::AV_PIX_FMT_DRM_PRIME); + me.va_frames = mk_frames(me.va_device, AVPixelFormat::AV_PIX_FMT_VAAPI); + if me.drm_frames.is_null() || me.va_frames.is_null() { + continue; + } + // Le pilote decide. Une chaine illisible compte comme « pas iHD » : on + // garde alors le chemin direct, celui d'avant la copie. + if va_vendor(me.va_device).is_some_and(|v| needs_vpp_copy(&v)) + && me.build_vpp().is_none() + { + continue; + } + + me.ctx = avcodec_alloc_context3(enc); + if me.ctx.is_null() { + continue; + } + (*me.ctx).width = w; + (*me.ctx).height = h; + (*me.ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_VAAPI as i32; + (*me.ctx).time_base = AVRational { num: 1, den: fps }; + (*me.ctx).framerate = AVRational { num: fps, den: 1 }; + (*me.ctx).bit_rate = bit_rate; + // MEME INTERVALLE D'IMAGES CLES QUE LE CHEMIN SOFTWARE, et pour la meme + // raison : deux secondes est le compromis choisi pour un fichier de + // sortie, il n'a pas a dependre de l'encodeur qui se trouve disponible. + // + // Sans cette ligne le resultat est correct A 60 FPS ET NULLE PART + // AILLEURS : `h264_vaapi` a un defaut de 120 frames (mesure), qui vaut + // deux secondes a 60 fps par coincidence. A 30 fps ca donnerait quatre + // secondes, a 120 fps une seule. Le defaut de `libopenh264` est -1, ce + // qui est un autre probleme encore (une seule image cle pour tout le + // fichier) traite dans `try_open`. + (*me.ctx).gop_size = (fps * 2).max(1); + // MP4 veut SPS/PPS dans l'extradata, pas repetes devant chaque image + // cle. Le chemin software le pose depuis toujours (`try_open`) ; l'avoir + // oublie ici produisait un fichier qui se lit quand meme, parce que le + // muxer recupere ce qu'il trouve — mais un lecteur qui se fie a + // `codecpar` seul aurait de quoi echouer. + (*me.ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as i32; + // La matrice des frames que `send_dmabuf` pose, celle de `yuv.wgsl`. + tag_export_color(me.ctx, AVColorSpace::AVCOL_SPC_SMPTE170M); + // Avec la copie, les surfaces de SORTIE du VPP, pas celles du mapping : + // c'est elles que l'encodeur recoit. + let out_frames = if me.graph.is_null() { + me.va_frames + } else { + av_buffersink_get_hw_frames_ctx(me.vpp_out) + }; + if out_frames.is_null() { + continue; + } + (*me.ctx).hw_frames_ctx = av_buffer_ref(out_frames); + if avcodec_open2(me.ctx, enc, ptr::null_mut()) < 0 { + continue; + } + return Some(me); } - Some(me) + None } /// Construit `buffer (VAAPI, surface mappee) -> scale_vaapi -> buffersink`. diff --git a/electron/native-bridge/services/compositorViewService.ts b/electron/native-bridge/services/compositorViewService.ts index 8bb4e888a3..4f9b708cab 100644 --- a/electron/native-bridge/services/compositorViewService.ts +++ b/electron/native-bridge/services/compositorViewService.ts @@ -664,6 +664,27 @@ export class CompositorViewService { ); } + /** + * Proportionally scales a Linux preview rectangle down so its largest side + * does not exceed `OPENSCREEN_LINUX_PREVIEW_MAX_DIM` (default 854 px), + * rounding dimensions down to even numbers. + */ + private scaleLinuxPreviewRect(rect: CompositorViewRect): CompositorViewRect { + if (process.platform !== "linux") { + return rect; + } + const maxDim = Number(process.env.OPENSCREEN_LINUX_PREVIEW_MAX_DIM) || 854; + if (maxDim < 2 || (rect.width <= maxDim && rect.height <= maxDim)) { + return rect; + } + const scale = maxDim / Math.max(rect.width, rect.height); + return { + ...rect, + width: Math.max(2, Math.floor((rect.width * scale) / 2) * 2), + height: Math.max(2, Math.floor((rect.height * scale) / 2) * 2), + }; + } + /** Allocates an offscreen compositor view sized to `rect.width`x`rect.height`. * `rect.x` / `rect.y` are vestigial (ignored native-side) — the renderer * keeps them on the wire so the existing `CompositorViewRect` shape stays @@ -673,6 +694,7 @@ export class CompositorViewService { rect: CompositorViewRect, paths?: { screenPath?: string; webcamPath?: string; cursorPath?: string }, ): number { + const scaledRect = this.scaleLinuxPreviewRect(rect); const addon = this.ensureAddon(); if (!addon) { // synthetic negative ids let callers do bookkeeping @@ -681,11 +703,16 @@ export class CompositorViewService { // stay independent. this.syntheticIdCounter -= 1; const id = this.syntheticIdCounter; - this.rects.set(id, rect); + this.rects.set(id, scaledRect); return id; } - const id = addon.createView(rect, paths?.screenPath, paths?.webcamPath, paths?.cursorPath); - this.rects.set(id, rect); + const id = addon.createView( + scaledRect, + paths?.screenPath, + paths?.webcamPath, + paths?.cursorPath, + ); + this.rects.set(id, scaledRect); this.shareFrames(addon, id); return id; } @@ -721,13 +748,15 @@ export class CompositorViewService { this.ensureAddon()?.setSharedFrames?.(id, false); } + /** Updates the offscreen compositor view dimensions for `id`. */ setRect(id: number, rect: CompositorViewRect): void { + const scaledRect = this.scaleLinuxPreviewRect(rect); const addon = this.ensureAddon(); - this.rects.set(id, rect); + this.rects.set(id, scaledRect); if (!addon) { return; } - addon.setRect(id, rect); + addon.setRect(id, scaledRect); } /** Reads the most recently rendered frame for `id`, but only if its generation is newer diff --git a/electron/native/pipewire-capture/build.rs b/electron/native/pipewire-capture/build.rs index 72b3121d17..7ae76bb10d 100644 --- a/electron/native/pipewire-capture/build.rs +++ b/electron/native/pipewire-capture/build.rs @@ -134,6 +134,7 @@ fn ffmpeg_dir(root: &Path) -> PathBuf { root.join("../../../crates/thirdparty/ffmpeg-linux64-lgpl-shared") } +/// Configures Cargo link directives and bindgen for the vendored FFmpeg libraries. fn link_ffmpeg(dir: &Path) { let include = dir.join("include"); let lib = dir.join("lib"); @@ -171,6 +172,7 @@ fn link_ffmpeg(dir: &Path) { // of the repo. `--disable-new-dtags` is what makes these RUNPATH entries // apply to the transitive ffmpeg libs too; with the default DT_RUNPATH they // would not. + println!("cargo:rustc-link-arg=-Wl,--export-dynamic"); println!("cargo:rustc-link-arg=-Wl,--disable-new-dtags"); println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN/helper-ffmpeg"); println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib.display()); diff --git a/electron/native/pipewire-capture/csrc/ffmpeg_accessors.c b/electron/native/pipewire-capture/csrc/ffmpeg_accessors.c index 0001bcde58..8a9de8f309 100644 --- a/electron/native/pipewire-capture/csrc/ffmpeg_accessors.c +++ b/electron/native/pipewire-capture/csrc/ffmpeg_accessors.c @@ -1,5 +1,90 @@ +#define _GNU_SOURCE +#include +#include #include +/* Compatibility shim for libva < 2.22 (e.g. Ubuntu 24.04 LTS with libva 2.20). + * The vendored FFmpeg 8.1 build links libva via implib-gen and resolves + * vaMapBuffer2 at runtime via dlsym(). On libva < 2.22, vaMapBuffer2 is absent + * and the implib trampoline aborts the process. When dlsym(handle, "vaMapBuffer2") + * returns NULL on a handle that exports vaMapBuffer, forward vaMapBuffer2 to + * vaMapBuffer(dpy, buf_id, pbuf). */ +typedef int VAStatus; +typedef void *VADisplay; +typedef unsigned int VABufferID; + +static void *(*real_dlsym_fn)(void *, const char *) = NULL; +static VAStatus (*real_vaMapBuffer_fn)(VADisplay, VABufferID, void **) = NULL; + +#if defined(__aarch64__) +#define OSC_DLSYM_GLIBC_VER "GLIBC_2.17" +#else +#define OSC_DLSYM_GLIBC_VER "GLIBC_2.2.5" +#endif + +/** + * Resolves the underlying glibc `dlsym` implementation using the architecture's + * base symbol version (`GLIBC_2.17` on aarch64, `GLIBC_2.2.5` on x86_64) with a + * `GLIBC_2.34` fallback so `real_dlsym_fn` is never left NULL when available. + */ +static void resolve_real_dlsym(void) +{ + if (!real_dlsym_fn) { + real_dlsym_fn = (void *(*)(void *, const char *))dlvsym(RTLD_NEXT, "dlsym", OSC_DLSYM_GLIBC_VER); + if (!real_dlsym_fn) { + real_dlsym_fn = (void *(*)(void *, const char *))dlvsym(RTLD_DEFAULT, "dlsym", OSC_DLSYM_GLIBC_VER); + } + if (!real_dlsym_fn) { + real_dlsym_fn = (void *(*)(void *, const char *))dlvsym(RTLD_NEXT, "dlsym", "GLIBC_2.34"); + } + if (!real_dlsym_fn) { + real_dlsym_fn = (void *(*)(void *, const char *))dlvsym(RTLD_DEFAULT, "dlsym", "GLIBC_2.34"); + } + } +} + +/** + * Compatibility entry point for `vaMapBuffer2` (added in libva 2.22) that + * forwards buffer mapping calls to `vaMapBuffer` on older libva runtimes. + */ +VAStatus vaMapBuffer2(VADisplay dpy, VABufferID buf_id, void **pbuf, unsigned int flags) +{ + (void)flags; + if (!real_vaMapBuffer_fn) { + resolve_real_dlsym(); + void *h = dlopen("libva.so.2", RTLD_LAZY | RTLD_NOLOAD); + if (!h) { + h = dlopen("libva.so.2", RTLD_LAZY); + } + if (h && real_dlsym_fn) { + real_vaMapBuffer_fn = + (VAStatus (*)(VADisplay, VABufferID, void **))real_dlsym_fn(h, "vaMapBuffer"); + } + } + if (!real_vaMapBuffer_fn) { + return -1; + } + return real_vaMapBuffer_fn(dpy, buf_id, pbuf); +} + +/** + * Interposes `dlsym` so lookups for `vaMapBuffer2` fall back to our + * `vaMapBuffer`-backed shim when the loaded `libva.so.2` predates 2.22. + */ +void *dlsym(void *handle, const char *name) +{ + resolve_real_dlsym(); + void *sym = real_dlsym_fn ? real_dlsym_fn(handle, name) : NULL; + if (!sym && name && strcmp(name, "vaMapBuffer2") == 0) { + void *map1 = real_dlsym_fn ? real_dlsym_fn(handle, "vaMapBuffer") : NULL; + if (map1) { + real_vaMapBuffer_fn = (VAStatus (*)(VADisplay, VABufferID, void **))map1; + return (void *)&vaMapBuffer2; + } + } + return sym; +} + /* FFmpeg 8 exposes AVFormatContext as opaque to bindgen on some clang builds. * Keep these field reads in C, compiled against the exact headers being linked. */ AVIOContext **osc_avformat_pb(AVFormatContext *context) diff --git a/electron/native/pipewire-capture/src/dmabuf_import.rs b/electron/native/pipewire-capture/src/dmabuf_import.rs index 59e900a84a..ce06b125dc 100644 --- a/electron/native/pipewire-capture/src/dmabuf_import.rs +++ b/electron/native/pipewire-capture/src/dmabuf_import.rs @@ -107,6 +107,14 @@ impl DmabufImporter { /// incoming dmabuf size (the whole stream); `out` is the recorded size — equal /// to `src` for a monitor, or the window's crop rectangle for a window (the /// graph then crops the source region down to it, on the GPU). + /// + /// Probes `/dev/dri/renderD128` first (unchanged default for working single-GPU + /// systems) and falls back across `/dev/dri/renderD129..renderD135` if DRM or + /// VAAPI setup fails. Because dmabuf mapping itself (`av_hwframe_map`) can only + /// be validated when frames arrive in [`Self::import`], hybrid-GPU hosts where + /// the default node opens VAAPI but drops imported compositor frames can set + /// `OPENSCREEN_LINUX_RENDER_NODE` as an optional workaround to select the + /// compositor's render node directly. pub fn new( src_width: i32, src_height: i32, @@ -119,21 +127,6 @@ impl DmabufImporter { // SAFETY: every pointer is checked before use and freed in Drop. unsafe { - let mut me = DmabufImporter { - src_width, - src_height, - out_width, - out_height, - sw_format, - va_device: ptr::null_mut(), - drm_device: ptr::null_mut(), - drm_frames: ptr::null_mut(), - va_map_frames: ptr::null_mut(), - graph: ptr::null_mut(), - buffersrc_ctx: ptr::null_mut(), - buffersink_ctx: ptr::null_mut(), - }; - // Order matters: create the DRM device on the render node FIRST, then // derive VAAPI from it. The reverse (DRM derived from VAAPI) returns // ENOSYS on radeonsi — VAAPI knows how to open on a DRM fd, but not the @@ -142,40 +135,92 @@ impl DmabufImporter { // /dev/dri/renderD128 is the default, but on hybrid graphics the // compositor may render on a different node — mapping a dmabuf from the // wrong GPU then fails on every frame, with `available()` still passing. - // Honour an override so such a machine can point at the right node - // without a rebuild; the single-GPU default is unchanged. - let node_path = std::env::var("OPENSCREEN_LINUX_RENDER_NODE") - .unwrap_or_else(|_| "/dev/dri/renderD128".to_owned()); - let node = std::ffi::CString::new(node_path) - .map_err(|_| "OPENSCREEN_LINUX_RENDER_NODE has an interior NUL byte".to_owned())?; - let created = ff::av_hwdevice_ctx_create( - &mut me.drm_device, - ff::AV_HWDEVICE_TYPE_DRM, - node.as_ptr(), - ptr::null_mut(), - 0, - ); - if created < 0 { - return Err(format!("av_hwdevice_ctx_create(DRM): {}", ff::err_to_string(created))); + // Honour `OPENSCREEN_LINUX_RENDER_NODE` as an optional workaround when + // frames are dropped on the default node; single-GPU systems with a + // working `renderD128` do not need to set it. + let mut candidates: Vec = Vec::new(); + if let Ok(explicit) = std::env::var("OPENSCREEN_LINUX_RENDER_NODE") { + if !explicit.is_empty() { + candidates.push(explicit); + } + } else { + for idx in 128..=135 { + let p = format!("/dev/dri/renderD{idx}"); + if std::path::Path::new(&p).exists() { + candidates.push(p); + } + } } - - let derived = ff::av_hwdevice_ctx_create_derived( - &mut me.va_device, - ff::AV_HWDEVICE_TYPE_VAAPI, - me.drm_device, - 0, - ); - if derived < 0 { - return Err(format!( - "av_hwdevice_ctx_create_derived(VAAPI): {}", - ff::err_to_string(derived) - )); + if candidates.is_empty() { + candidates.push("/dev/dri/renderD128".to_owned()); } - me.drm_frames = me.alloc_frames(me.drm_device, ff::AV_PIX_FMT_DRM_PRIME)?; - me.va_map_frames = me.alloc_frames(me.va_device, ff::AV_PIX_FMT_VAAPI)?; - me.build_graph()?; - Ok(me) + let mut last_err = String::from("no DRM/VAAPI render node available"); + for node_path in candidates { + let node = std::ffi::CString::new(node_path) + .map_err(|_| "OPENSCREEN_LINUX_RENDER_NODE has an interior NUL byte".to_owned())?; + let mut me = DmabufImporter { + src_width, + src_height, + out_width, + out_height, + sw_format, + va_device: ptr::null_mut(), + drm_device: ptr::null_mut(), + drm_frames: ptr::null_mut(), + va_map_frames: ptr::null_mut(), + graph: ptr::null_mut(), + buffersrc_ctx: ptr::null_mut(), + buffersink_ctx: ptr::null_mut(), + }; + + let created = ff::av_hwdevice_ctx_create( + &mut me.drm_device, + ff::AV_HWDEVICE_TYPE_DRM, + node.as_ptr(), + ptr::null_mut(), + 0, + ); + if created < 0 { + last_err = format!("av_hwdevice_ctx_create(DRM): {}", ff::err_to_string(created)); + continue; + } + + let derived = ff::av_hwdevice_ctx_create_derived( + &mut me.va_device, + ff::AV_HWDEVICE_TYPE_VAAPI, + me.drm_device, + 0, + ); + if derived < 0 { + last_err = format!( + "av_hwdevice_ctx_create_derived(VAAPI): {}", + ff::err_to_string(derived) + ); + continue; + } + + me.drm_frames = match me.alloc_frames(me.drm_device, ff::AV_PIX_FMT_DRM_PRIME) { + Ok(frames) => frames, + Err(err) => { + last_err = err; + continue; + } + }; + me.va_map_frames = match me.alloc_frames(me.va_device, ff::AV_PIX_FMT_VAAPI) { + Ok(frames) => frames, + Err(err) => { + last_err = err; + continue; + } + }; + if let Err(err) = me.build_graph() { + last_err = err; + continue; + } + return Ok(me); + } + Err(last_err) } } diff --git a/electron/native/pipewire-capture/src/encoder.rs b/electron/native/pipewire-capture/src/encoder.rs index 5f0a2fe49e..9afa29483a 100644 --- a/electron/native/pipewire-capture/src/encoder.rs +++ b/electron/native/pipewire-capture/src/encoder.rs @@ -165,15 +165,21 @@ pub fn prepare_environment() { /// against, and what matters is what is loadable here and now. fn vaapi_is_safe_to_probe() -> bool { // SAFETY: dlopen/dlsym with a literal name; every pointer is checked before - // use and the handle is closed on both paths. + // use and the handle is closed on both paths. csrc/ffmpeg_accessors.c + // provides a vaMapBuffer2 -> vaMapBuffer compatibility bridge for libva < 2.22. unsafe { let handle = dlopen(c"libva.so.2".as_ptr(), RTLD_LAZY | RTLD_LOCAL); if handle.is_null() { return false; } let symbol = dlsym(handle, c"vaMapBuffer2".as_ptr()); + let fallback = if symbol.is_null() { + dlsym(handle, c"vaMapBuffer".as_ptr()) + } else { + symbol + }; dlclose(handle); - !symbol.is_null() + !fallback.is_null() } } @@ -484,40 +490,71 @@ impl VideoEncoder { return Ok(()); } - let created = ff::av_hwdevice_ctx_create( - &mut self.hw_device, - device_type, - ptr::null(), - ptr::null_mut(), - 0, - ); - if created < 0 { - return Err(format!( - "av_hwdevice_ctx_create: {}", - ff::err_to_string(created) - )); + let mut candidate_nodes: Vec> = Vec::new(); + if device_type == ff::AV_HWDEVICE_TYPE_VAAPI { + if let Ok(explicit) = std::env::var("OPENSCREEN_LINUX_RENDER_NODE") { + if let Ok(cstr) = CString::new(explicit) { + candidate_nodes.push(Some(cstr)); + } + } + candidate_nodes.push(None); + for idx in 128..=135 { + let path = format!("/dev/dri/renderD{idx}"); + if Path::new(&path).exists() { + if let Ok(cstr) = CString::new(path) { + candidate_nodes.push(Some(cstr)); + } + } + } + } else { + candidate_nodes.push(None); } - self.hw_frames = ff::av_hwframe_ctx_alloc(self.hw_device); - if self.hw_frames.is_null() { - return Err("av_hwframe_ctx_alloc returned null".to_owned()); + let mut last_err = String::from("no hardware device available"); + let mut ready = false; + for node in &candidate_nodes { + let device_ptr = node.as_ref().map_or(ptr::null(), |c| c.as_ptr()); + let created = ff::av_hwdevice_ctx_create( + &mut self.hw_device, + device_type, + device_ptr, + ptr::null_mut(), + 0, + ); + if created < 0 { + last_err = format!("av_hwdevice_ctx_create: {}", ff::err_to_string(created)); + continue; + } + + self.hw_frames = ff::av_hwframe_ctx_alloc(self.hw_device); + if self.hw_frames.is_null() { + last_err = "av_hwframe_ctx_alloc returned null".to_owned(); + ff::av_buffer_unref(&mut self.hw_device); + continue; + } + let frames_ctx = (*self.hw_frames).data as *mut ff::AVHWFramesContext; + (*frames_ctx).format = self.backend.codec_pixel_format(); + (*frames_ctx).sw_format = self.backend.upload_format(); + (*frames_ctx).width = params.width; + (*frames_ctx).height = params.height; + // Enough surfaces that the encoder can hold a few references while we + // are filling the next one. Too small a pool stalls; too large just + // wastes VRAM. + (*frames_ctx).initial_pool_size = 8; + + let initialised = ff::av_hwframe_ctx_init(self.hw_frames); + if initialised < 0 { + last_err = format!("av_hwframe_ctx_init: {}", ff::err_to_string(initialised)); + ff::av_buffer_unref(&mut self.hw_frames); + ff::av_buffer_unref(&mut self.hw_device); + continue; + } + + ready = true; + break; } - let frames_ctx = (*self.hw_frames).data as *mut ff::AVHWFramesContext; - (*frames_ctx).format = self.backend.codec_pixel_format(); - (*frames_ctx).sw_format = self.backend.upload_format(); - (*frames_ctx).width = params.width; - (*frames_ctx).height = params.height; - // Enough surfaces that the encoder can hold a few references while we - // are filling the next one. Too small a pool stalls; too large just - // wastes VRAM. - (*frames_ctx).initial_pool_size = 8; - - let initialised = ff::av_hwframe_ctx_init(self.hw_frames); - if initialised < 0 { - return Err(format!( - "av_hwframe_ctx_init: {}", - ff::err_to_string(initialised) - )); + if !ready { + return Err(last_err); } (*self.codec_ctx).hw_frames_ctx = ff::av_buffer_ref(self.hw_frames); diff --git a/electron/windows.ts b/electron/windows.ts index 8db7d9cd4a..f895214526 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -165,6 +165,21 @@ function hudBounds(win: BrowserWindow): Electron.Rectangle { return { x, y, ...hudWindowSize }; } +/** + * Applies `setBounds` to the HUD window. On Linux/X11, window managers such as + * Mutter ignore programmatic size changes when `resizable: false` locks + * `WM_NORMAL_HINTS` min/max dimensions, so `resizable` is toggled around the call. + */ +function setHudWindowBounds(win: BrowserWindow, next: Electron.Rectangle) { + if (process.platform === "linux") { + win.setResizable(true); + win.setBounds(next, false); + win.setResizable(false); + } else { + win.setBounds(next, false); + } +} + // Origin the current drag gesture started from. The renderer sends the pointer's // *total* travel since pointerdown rather than per-frame deltas, so every move is // an absolute `origin + delta` — no rounding to accumulate, and a dropped message @@ -196,9 +211,11 @@ function hudWorkAreaFor(bounds: Electron.Rectangle): Electron.Rectangle { return screen.getDisplayMatching(reference).workArea; } -// Re-clamps a HUD nobody is dragging: a drag owns the position frame by frame, and the -// renderer re-measures on release. Skipped while minimized, where moving the window is -// platform-defined; "restore" runs it instead. +/** + * Re-clamps a HUD nobody is dragging: a drag owns the position frame by frame, and the + * renderer re-measures on release. Skipped while minimized, where moving the window is + * platform-defined; "restore" runs it instead. + */ function reclampHud() { const win = hudOverlayWindow; if (!win || win.isDestroyed() || win.isMinimized() || hudDragOrigin) return; @@ -217,7 +234,7 @@ function reclampHud() { }) : clampHudBoundsToWorkArea(bounds, hudContentRect, workArea); if (!sameRect(next, bounds)) { - win.setBounds(next, false); + setHudWindowBounds(win, next); } } @@ -435,7 +452,7 @@ ipcMain.on("hud-overlay-set-size", (_event, width: number, height: number, conte hudWindowSize = { width: next.width, height: next.height }; if (!sameRect(next, bounds)) { - hudOverlayWindow.setBounds(next, false); + setHudWindowBounds(hudOverlayWindow, next); } reassertAlwaysOnTop(hudOverlayWindow); }); @@ -460,9 +477,11 @@ export function createHudOverlayWindow(): BrowserWindow { // Close to what the renderer asks for on its very first measurement (bar plus // the reserved space above it), so the HUD doesn't visibly resize itself the - // instant it becomes visible. See src/components/launch/hudGeometry.ts. - const windowWidth = 820; - const windowHeight = 560; + // instant it becomes visible. On Linux the HUD is not click-through, so start + // at the compact bar+tooltip size until a popover opens. + const isLinux = process.platform === "linux"; + const windowWidth = isLinux ? 440 : 820; + const windowHeight = isLinux ? 190 : 560; const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); const y = Math.floor(workArea.y + workArea.height - windowHeight - 5); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 50df3e6450..b7ecb0eed8 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -397,7 +397,12 @@ export function LaunchWindow() { // screen.availHeight (which a window resize can't change) and are pushed // down as CSS custom properties. // --------------------------------------------------------------------------- - const hudAllocatedSizeRef = useRef({ width: 0, height: 0, orientation: trayLayout }); + const hudAllocatedSizeRef = useRef({ + width: 0, + height: 0, + orientation: trayLayout, + popoverOpen: isPopoverOpen, + }); // Last stack rect sent to the main process, so a measurement that changed nothing // costs no IPC. const lastSentHudContentRef = useRef<{ @@ -434,32 +439,39 @@ export function LaunchWindow() { ? noticeEl.getBoundingClientRect().height || noticeEl.scrollHeight : 0; - // The two floating surfaces get their own CSS caps (a 470px-tall language - // list would look absurd), but the *window* always reserves room for the - // taller of them. Sizing the reserve to whichever happens to be open would - // mean the window grows when the panel opens — and since the stack is - // bottom-anchored, growing upward moves every bit of content down in window - // coordinates, which the renderer repaints a frame or two after the native - // resize lands. That gap is visible as exactly the position judder this - // whole architecture exists to remove. So: reserve the maximum, always. const popoverMaxHeight = computeHudPopoverMaxHeight(barHeight, availableHeight); const modalMaxHeight = computeHudModalMaxHeight(barHeight, availableHeight); const anchorEl = hudAnchorRef.current; anchorEl?.style.setProperty("--hud-popover-max-h", `${popoverMaxHeight}px`); anchorEl?.style.setProperty("--hud-modal-max-h", `${modalMaxHeight}px`); - const { required, granted } = computeHudWindowSize({ + // On Linux, the HUD window is never click-through (setHudMouseEventsEnabled), + // so reserving 500+ px of empty height when no popover is open blocks clicks + // across the center of the desktop. Reserve only tooltip clearance (86 px -> + // ~190 px total height) while collapsed, and expand when a popover opens. + const isLinux = isLinuxHud || getPlatform() === "linux"; + const collapsedLinux = isLinux && !isPopoverOpen; + const computed = computeHudWindowSize({ barWidth, barHeight, noticeHeight, availableHeight, - stackMaxHeight: Math.max(popoverMaxHeight, modalMaxHeight), + stackMaxHeight: collapsedLinux ? 86 : Math.max(popoverMaxHeight, modalMaxHeight), }); + const required = computed.required; + const granted = collapsedLinux + ? { + width: trayLayout === "horizontal" ? Math.max(440, required.width) : required.width, + height: Math.max(190, required.height), + } + : computed.granted; const allocated = hudAllocatedSizeRef.current; // A different orientation is a different shape entirely (wide-short vs // narrow-tall), so the previous allocation says nothing useful. - const orientationChanged = allocated.orientation !== trayLayout; + const orientationChanged = + allocated.orientation !== trayLayout || + (isLinux && allocated.popoverOpen !== isPopoverOpen); // Grow the moment the content stops fitting. Shrink only once the content // has fallen a whole reserve below what was granted — that asymmetry is the // hysteresis: the bar can grow into its reserve (recording controls, a @@ -521,11 +533,12 @@ export function LaunchWindow() { } allocated.orientation = trayLayout; + allocated.popoverOpen = isPopoverOpen; allocated.width = granted.width; allocated.height = granted.height; lastSentHudContentRef.current = grantedContent; window.electronAPI.setHudOverlaySize(granted.width, granted.height, grantedContent); - }, [trayLayout]); + }, [trayLayout, isPopoverOpen, isLinuxHud]); // One persistent observer; elements wire themselves up via callback refs as // they mount/unmount. The bar and the notice column size the window; the anchor