Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
572dd2f
perf(linux): read write-combined dmabufs with streaming loads
shilai-li Oct 27, 2026
407da2d
fix(linux): stamp frames by arrival slot, not encode time
shilai-li Oct 27, 2026
74c03cc
perf(linux): thread colour conversion via sws_scale_frame
shilai-li Oct 27, 2026
7237e1b
fix(linux): pass the real dmabuf size to the VA-API import
shilai-li Oct 27, 2026
4d646ed
fix(linux): fall back to the embedded cursor when the portal lacks ME…
shilai-li Oct 27, 2026
5a9914b
feat(linux): cursor positions and click zooms on Hyprland
shilai-li Oct 27, 2026
a4382f8
fix(linux): poll Hyprland's pointer at a steady rate
shilai-li Oct 27, 2026
2a85ef8
fix(linux): size the HUD window to the bar alone
shilai-li Oct 27, 2026
5bd9808
feat(linux): ship a pointer-only udev rule for click capture
shilai-li Oct 27, 2026
0c53d7a
perf(linux): import LINEAR dmabufs on the GPU when VA-API can
shilai-li Oct 28, 2026
6ffa746
perf(linux): read the dmabuf size without a dup per frame
shilai-li Oct 28, 2026
7a17de8
fix(linux): find the granted Hyprland monitor by its size
shilai-li Oct 28, 2026
26e1d3e
feat(linux): auto-zoom system-cursor takes on Hyprland
shilai-li Oct 28, 2026
dfb078f
fix(linux): keep audio and video aligned when the first frame waits f…
shilai-li Oct 28, 2026
0ccdf92
fix(linux): harden the Hyprland pointer path
shilai-li Oct 28, 2026
c5a6132
fix: keep painted-in takes painted-in
shilai-li Oct 28, 2026
6a973ae
fix(linux): HUD panel review fixes
shilai-li Oct 28, 2026
7ba7f5f
test(linux): tighten the arrival-placement test bounds
shilai-li Oct 28, 2026
e9e5bc2
fix(linux): translate the panel's toast dismiss label live
shilai-li Oct 28, 2026
af84a3e
Merge branch 'main' into feat/linux-hyprland
shilai-li Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ You may need to grant screen recording permissions depending on your desktop env
Everything in the editor and export is the same on macOS, Windows, and Linux: zooms, backgrounds, motion blur, crop/trim/speed, blur regions, annotations, auto-captions, AI editing, projects, export, and all languages. All three now record through a native capture pipeline; the remaining differences are narrower than they used to be:

- **Native recording**: macOS (ScreenCaptureKit), Windows (Windows Graphics Capture), and Linux (PipeWire via the ScreenCast portal) all record through a native pipeline for higher quality and clean window-level capture. On Linux the browser pipeline stays as an automatic fallback if the helper isn't available.
- **Custom cursors**: on macOS and Windows the real cursor is captured with shape, type, and clicks. Linux captures position and cursor shape through the portal, so the editable cursor overlay works there too. Click effects work on Linux as well, but not through the portal — Wayland exposes no portal for mouse buttons, so the capture helper reads the left button from evdev, which needs read access to your mouse: a udev rule for pointer devices (recommended) or the `input` group, set up as [Mouse clicks on Wayland](https://getopenscreen.com/docs/installation#mouse-clicks-on-wayland) shows. Without that, recording is unaffected and every cursor sample is simply a move.
- **Custom cursors**: on macOS and Windows the real cursor is captured with shape, type, and clicks. Linux captures position and cursor shape through the portal, so the editable cursor overlay works there too. Click effects work on Linux as well, but not through the portal — Wayland exposes no portal for mouse buttons, so the capture helper reads the left button from evdev, which needs read access to your mouse. The `.deb`, `.rpm` and `.pacman` packages install a udev rule that grants it for mice and touchpads only, never keyboards; with the AppImage, add that rule by hand as [Mouse clicks on Wayland](https://getopenscreen.com/docs/installation#mouse-clicks-on-wayland) shows (the `input` group also works, but opens keyboards too and is not recommended). Without that, recording is unaffected and every cursor sample is simply a move.
- **Webcam**: every platform saves the webcam as a separate file next to the screen recording. Windows captures it natively in the recording helper; macOS and Linux record it in the app. It works as a picture-in-picture overlay on all three.
- **System audio** support varies by OS:
- **macOS**: works on every supported version. On macOS 14.2+ you'll be prompted to grant audio capture permission.
Expand Down
16 changes: 16 additions & 0 deletions build/linux/70-openscreen-pointer.rules
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# OpenScreen click zooms: let the logged-in user read mice and touchpads, never keyboards.
#
# The Linux capture helper reads left-button presses from /dev/input/event* (Wayland has no
# portal for mouse buttons; see electron/native/pipewire-capture/src/input.rs). Those nodes
# are root:input. The `input` group would open every input device, keyboards included, to
# every program the user runs. `uaccess` instead has systemd-logind put an ACL for the user
# of the active local session on the matched nodes, and on nothing else. A node that reports
# any keyboard key (ID_INPUT_KEY) is never matched, even when it is also a mouse.
#
# The 70- prefix is load-bearing: the tag has to be set before systemd's 73-seat-late.rules,
# which is where uaccess is applied. The deb, rpm and pacman packages install this file to
# /usr/lib/udev/rules.d/, and the NixOS module adds it to services.udev.packages. For the
# AppImage, copy it to /etc/udev/rules.d/ by hand: website/docs/installation.md,
# "Mouse clicks on Wayland".
SUBSYSTEM=="input", KERNEL=="event*", ENV{ID_INPUT_KEY}!="1", ENV{ID_INPUT_MOUSE}=="1", TAG+="uaccess"
SUBSYSTEM=="input", KERNEL=="event*", ENV{ID_INPUT_KEY}!="1", ENV{ID_INPUT_TOUCHPAD}=="1", TAG+="uaccess"
76 changes: 76 additions & 0 deletions build/linux/after-install.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/bin/bash

if type update-alternatives >/dev/null 2>&1; then
# Remove previous link if it doesn't use update-alternatives
if [ -L '/usr/bin/${executable}' -a -e '/usr/bin/${executable}' -a "`readlink '/usr/bin/${executable}'`" != '/etc/alternatives/${executable}' ]; then
rm -f '/usr/bin/${executable}'
fi
update-alternatives --install '/usr/bin/${executable}' '${executable}' '/opt/${sanitizedProductName}/${executable}' 100 || ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}'
else
ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}'
fi

# Check if user namespaces are supported by the kernel and working with a quick test:
if ! { [[ -L /proc/self/ns/user ]] && unshare --user true; }; then
# Use SUID chrome-sandbox only on systems without user namespaces:
chmod 4755 '/opt/${sanitizedProductName}/chrome-sandbox' || true
else
chmod 0755 '/opt/${sanitizedProductName}/chrome-sandbox' || true
fi

if hash update-mime-database 2>/dev/null; then
update-mime-database /usr/share/mime || true
fi

if hash update-desktop-database 2>/dev/null; then
update-desktop-database /usr/share/applications || true
fi

# Install apparmor profile. (Ubuntu 24+)
# First check if the version of AppArmor running on the device supports our profile.
# This is in order to keep backwards compatibility with Ubuntu 22.04 which does not support abi/4.0.
# In that case, we just skip installing the profile since the app runs fine without it on 22.04.
#
# Those apparmor_parser flags are akin to performing a dry run of loading a profile.
# https://wiki.debian.org/AppArmor/HowToUse#Dumping_profiles
#
# Unfortunately, at the moment AppArmor doesn't have a good story for backwards compatibility.
# https://askubuntu.com/questions/1517272/writing-a-backwards-compatible-apparmor-profile
if apparmor_status --enabled > /dev/null 2>&1; then
APPARMOR_PROFILE_SOURCE='/opt/${sanitizedProductName}/resources/apparmor-profile'
APPARMOR_PROFILE_TARGET='/etc/apparmor.d/${executable}'
if apparmor_parser --skip-kernel-load --debug "$APPARMOR_PROFILE_SOURCE" > /dev/null 2>&1; then
cp -f "$APPARMOR_PROFILE_SOURCE" "$APPARMOR_PROFILE_TARGET"

# Updating the current AppArmor profile is not possible and probably not meaningful in a chroot'ed environment.
# Use cases are for example environments where images for clients are maintained.
# There, AppArmor might correctly be installed, but live updating makes no sense.
if ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; } && hash apparmor_parser 2>/dev/null; then
# Extra flags taken from dh_apparmor:
# > By using '-W -T' we ensure that any abstraction updates are also pulled in.
# https://wiki.debian.org/AppArmor/Contribute/FirstTimeProfileImport
apparmor_parser --replace --write-cache --skip-read-cache "$APPARMOR_PROFILE_TARGET"
fi
else
echo "Skipping the installation of the AppArmor profile as this version of AppArmor does not seem to support the bundled profile"
fi
fi

# ---------------------------------------------------------------------------------------
# OpenScreen. Everything above is app-builder-lib's templates/linux/after-install.tpl,
# verbatim: naming a script in electron-builder.json5 REPLACES that template instead of
# extending it, and it is what creates /usr/bin/openscreen and sets chrome-sandbox's mode.
# scripts/linux-package-scripts.test.mjs fails when the two drift apart.
#
# The package ships /usr/lib/udev/rules.d/70-openscreen-pointer.rules (build/linux/), which
# gives the logged-in user read access to mice and touchpads for click capture. udev only
# applies a rule to an event, so a mouse that is already plugged in would wait for a replug
# or a reboot; reloading and replaying a change event on the input devices applies it now.
#
# Skipped where no udev daemon is listening (a container, a chroot, an image build): there
# is nothing to reload, and the rule applies on the first boot anyway. Never fatal.
if command -v udevadm >/dev/null 2>&1 && [ -S /run/udev/control ] \
&& ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; }; then
udevadm control --reload-rules || true
udevadm trigger --subsystem-match=input --action=change || true
fi
46 changes: 46 additions & 0 deletions build/linux/after-remove.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/bin/bash

# Delete the link to the binary
# update-alternatives --remove <name> <path>: 'path' must be the registered alternative binary,
# not the generic symlink — see https://man7.org/linux/man-pages/man1/update-alternatives.1.html
if type update-alternatives >/dev/null 2>&1; then
update-alternatives --remove '${executable}' '/opt/${sanitizedProductName}/${executable}'
else
rm -f '/usr/bin/${executable}'
fi

APPARMOR_PROFILE_DEST='/etc/apparmor.d/${executable}'

# Remove and unload apparmor profile.
if [ -f "$APPARMOR_PROFILE_DEST" ]; then
# Unload the profile from the running kernel before deleting the file so the
# policy is not left enforced until the next reboot. Mirror the chroot guard
# used in the after-install script — live AppArmor operations are not
# meaningful inside a chroot.
# https://wiki.debian.org/AppArmor/HowToUse
if apparmor_status --enabled > /dev/null 2>&1; then
if ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; } && hash apparmor_parser 2>/dev/null; then
apparmor_parser --remove "$APPARMOR_PROFILE_DEST" || true
fi
fi
rm -f "$APPARMOR_PROFILE_DEST"
fi

# ---------------------------------------------------------------------------------------
# OpenScreen. Everything above is app-builder-lib's templates/linux/after-remove.tpl,
# verbatim: naming a script in electron-builder.json5 REPLACES that template instead of
# extending it. scripts/linux-package-scripts.test.mjs fails when the two drift apart.
#
# The package manager has already deleted 70-openscreen-pointer.rules by now. Reload so
# udev stops tagging pointer devices for uaccess, and replay a change event so the current
# devices are re-evaluated without it. An ACL logind already placed on a node can outlive
# this until the device is replugged or the machine reboots: udev has no event that
# withdraws one.
#
# This also runs on upgrade (deb postrm, rpm %postun), when the new package's copy of the
# rule is in place. Reloading then is harmless. Same guard as after-install.
if command -v udevadm >/dev/null 2>&1 && [ -S /run/udev/control ] \
&& ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; }; then
udevadm control --reload-rules || true
udevadm trigger --subsystem-match=input --action=change || true
fi
81 changes: 75 additions & 6 deletions crates/compositor/src/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ pub struct CursorTrack {
/// (`follow_in_view`). Partagé entre clones, qui ont les mêmes échantillons.
view_cache:
std::sync::Arc<std::sync::Mutex<Vec<(ViewKey, std::sync::Arc<Vec<(f32, f32, f32)>>)>>>,
/// Le pointeur est déjà PEINT dans les pixels (`<vidéo>.pointer.json`, cf. `load`) : la
/// piste sert au suivi auto du zoom, mais rien ne la dessine, ni sprite ni impact de clic,
/// sans quoi un second curseur se poserait sur le premier (`frame_geometry::cursor_alpha`).
painted_in: bool,
}

type ViewKey = (u32, u32, u32);
Expand Down Expand Up @@ -161,9 +165,15 @@ impl CursorTrack {
visibility,
types,
view_cache: Default::default(),
painted_in: false,
}
}

/// Vrai pour une piste chargée depuis un `.pointer.json` : voir le champ `painted_in`.
pub fn painted_in(&self) -> bool {
self.painted_in
}

/// Visibilité enregistrée au temps `t`. Les anciens sidecars qui n'ont pas ce champ sont
/// visibles par défaut, comme les lecteurs TypeScript.
pub fn visible_at(&self, t: f32) -> bool {
Expand All @@ -188,7 +198,28 @@ impl CursorTrack {
}

/// Charge la fenêtre [offset_ms, offset_ms + dur_s*1000] et la ramène à t=0.
///
/// Un `<vidéo>.cursor.json` absent retombe sur `<vidéo>.pointer.json` : une prise dont le
/// pointeur est peint dans l'image (Linux sous Hyprland, dont le portail ne sait pas l'en
/// sortir) ne sait que où il était. Résolu ICI, pas à chaque appelant : l'aperçu, l'export
/// MP4 et le GIF passent tous le chemin du `.cursor.json` et passent tous par ici.
pub fn load(path: &str, offset_ms: f64, dur_s: f64) -> Result<CursorTrack> {
match CursorTrack::load_file(path, offset_ms, dur_s) {
Err(error) if !std::path::Path::new(path).exists() => {
let Some(stem) = path.strip_suffix(".cursor.json") else { return Err(error) };
let pointer = format!("{stem}.pointer.json");
if !std::path::Path::new(&pointer).exists() {
return Err(error);
}
let mut track = CursorTrack::load_file(&pointer, offset_ms, dur_s)?;
track.painted_in = true;
Ok(track)
}
loaded => loaded,
}
}

fn load_file(path: &str, offset_ms: f64, dur_s: f64) -> Result<CursorTrack> {
let txt = std::fs::read_to_string(path).with_context(|| format!("lecture {path}"))?;
let v: serde_json::Value = serde_json::from_str(&txt)?;
let arr = v["samples"].as_array().context("samples[]")?;
Expand Down Expand Up @@ -471,12 +502,17 @@ impl CursorTrack {
/// bruts (le bounce est temporel, pas positionnel — ne doit pas suivre le lissage).
pub fn smoothed(&self, factor: f32) -> CursorTrack {
if self.samples.len() < 2 || factor <= 0.0 {
return CursorTrack::new_with_visibility(
self.samples.clone(),
self.clicks.clone(),
self.types.clone(),
self.visibility.clone(),
);
// Le marqueur suit ici aussi : lissage coupé, une prise peinte dessinait sinon un
// second curseur par-dessus le sien.
return CursorTrack {
painted_in: self.painted_in,
..CursorTrack::new_with_visibility(
self.samples.clone(),
self.clicks.clone(),
self.types.clone(),
self.visibility.clone(),
)
};
}
const STEP_S: f32 = 1.0 / 240.0;
let start = self.samples[0].0;
Expand Down Expand Up @@ -536,6 +572,7 @@ impl CursorTrack {
// cliqués restent ceux de la piste brute.
CursorTrack {
click_points: self.click_points.clone(),
painted_in: self.painted_in,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
..CursorTrack::new_with_visibility(
samples,
self.clicks.clone(),
Expand Down Expand Up @@ -1151,4 +1188,36 @@ mod tests {
// Et s'éteint 1.8s après le clic (4.0 + 1.8 = 5.8s)
assert_eq!(track.opacity_at(5.9, true), 0.0);
}

#[test]
fn falls_back_to_the_pointer_sidecar_of_a_painted_in_take() {
let dir = std::env::temp_dir().join(format!("pointer-sidecar-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let video = dir.join("take.mp4");
let cursor_json = format!("{}.cursor.json", video.to_str().expect("utf-8 path"));
let pointer_json = format!("{}.pointer.json", video.to_str().expect("utf-8 path"));
std::fs::write(
&pointer_json,
r#"{"samples":[{"timeMs":0,"cx":0.2,"cy":0.2},{"timeMs":1000,"cx":0.8,"cy":0.8}]}"#,
)
.expect("write pointer sidecar");

let track = CursorTrack::load(&cursor_json, 0.0, 2.0).expect("falls back");
assert!(track.painted_in());
assert!(track.at(1.0).is_some());
// Le lissage (slider « smoothing ») garde le marqueur : sinon le curseur réapparaît.
assert!(track.smoothed(0.5).painted_in());
// Lissage coupé (0) : le chemin court garde le marqueur, lui aussi.
assert!(track.smoothed(0.0).painted_in());

// Un `.cursor.json` présent l'emporte, et se dessine.
std::fs::write(&cursor_json, r#"{"samples":[{"timeMs":0,"cx":0.5,"cy":0.5}]}"#)
.expect("write cursor sidecar");
let drawn = CursorTrack::load(&cursor_json, 0.0, 2.0).expect("loads the drawable one");
assert!(!drawn.painted_in());
let _ = std::fs::remove_dir_all(&dir);

// Ni l'un ni l'autre : l'erreur d'origine, comme avant.
assert!(CursorTrack::load(&cursor_json, 0.0, 2.0).is_err());
}
}
37 changes: 35 additions & 2 deletions crates/compositor/src/frame_geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3777,7 +3777,8 @@ pub struct CursorPlanInput<'a> {
pub t: f32,
}

/// Opacité du curseur à `t`, avant placement : 0 quand il est masqué (`cursor.show`), sinon
/// Opacité du curseur à `t`, avant placement : 0 quand il est masqué (`cursor.show`) ou déjà
/// peint dans l'image (`CursorTrack::painted_in`), sinon
/// l'auto-hide × le `hideCursor` des régions de zoom. Partagée par `plan_cursor` et l'impact du
/// clic : le plan ne bascule que sous un pointeur qu'on voit.
pub fn cursor_alpha(
Expand All @@ -3787,7 +3788,7 @@ pub fn cursor_alpha(
track: &crate::cursor::CursorTrack,
t: f32,
) -> f32 {
if !scene.map(|s| s.cursor.show).unwrap_or(cfg.cursor) {
if !scene.map(|s| s.cursor.show).unwrap_or(cfg.cursor) || track.painted_in() {
return 0.0;
}
let idle_alpha = track.opacity_at(t, live.cursor_auto_hide);
Expand Down Expand Up @@ -8586,6 +8587,38 @@ mod tests {

/// clickBounce au maximum du slider (5) : le creux de la pression donnerait 1 - 0.24 * 5 < 0.
/// La taille ne doit jamais devenir négative ; au creux, rien n'est dessiné.
#[test]
fn plan_cursor_draws_nothing_for_a_pointer_painted_into_the_pixels() {
let cfg = crate::config::all().pop().expect("cfg");
let scene = zoomed_golden_scene();
let fg = full_frame_geometry();
let dir = std::env::temp_dir().join(format!("painted-in-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let video = dir.join("take.mp4");
std::fs::write(
dir.join("take.mp4.pointer.json"),
r#"{"samples":[{"timeMs":0,"cx":0.5,"cy":0.5},{"timeMs":500,"cx":0.5,"cy":0.5,"interactionType":"click"},{"timeMs":2000,"cx":0.5,"cy":0.5}]}"#,
)
.expect("write pointer sidecar");
let cursor_json = format!("{}.cursor.json", video.to_str().expect("utf-8 path"));
let track = crate::cursor::CursorTrack::load(&cursor_json, 0.0, 3.0).expect("load");
let _ = std::fs::remove_dir_all(&dir);

let input = |t: f32| CursorPlanInput {
render_px: [1920.0, 1080.0],
u_max: 1.0,
v_max: 1.0,
cfg: &cfg,
live: LiveParams::default(),
scene: Some(&scene),
track: &track,
t,
};
// Ni sprite ni anneau de clic, juste après le clic compris : le pointeur est dans l'image.
assert!(plan_cursor(&fg, &input(0.6)).is_none());
assert!(plan_cursor(&fg, &input(1.0)).is_none());
}

#[test]
fn plan_cursor_never_yields_a_negative_size_at_max_bounce() {
let cfg = crate::config::all().pop().expect("cfg");
Expand Down
Loading
Loading