Skip to content

Commit d77b62b

Browse files
author
Abhijeet Arch
committed
feat(linux): add Hyprland cursor tracking and position zoom
1 parent f09670c commit d77b62b

16 files changed

Lines changed: 986 additions & 168 deletions

File tree

‎crates/compositor/src/frame_geometry.rs‎

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3834,11 +3834,11 @@ impl<'a> CursorPlacer<'a> {
38343834
}
38353835
}
38363836

3837-
/// La pointe à `t`, posée sur le point cliqué brut à chaque clic (`pinned_at`), pas sur la piste
3838-
/// lissée qui traîne derrière la souris. Sprite plat comme modèle 3D : le rebond, l'anneau
3839-
/// d'impact et `ctx.clicks` se centrent sur ce point brut, la pointe doit s'y trouver.
3837+
/// La pointe à `t`. Le curseur modélisé touche le plan à chaque clic : il se pose sur le point
3838+
/// cliqué brut (`pinned_at`), pas sur la piste lissée qui traîne derrière la souris. Le sprite
3839+
/// plat garde la piste telle quelle, son rendu est celui d'avant.
38403840
fn at(&self, t: f32) -> Option<(f32, f32)> {
3841-
self.track.pinned_at(t)
3841+
if self.model3d { self.track.pinned_at(t) } else { self.track.at(t) }
38423842
}
38433843

38443844
/// `None` hors de la coupe courante (zoom serré, hors écran).
@@ -9279,28 +9279,20 @@ mod tests {
92799279
assert_eq!(fx(2.5).cursor, Some(drawn.placement.upright_center()), "là où le curseur se dessine");
92809280
}
92819281

9282-
/// Le sprite plat aussi se pose sur le clic brut : à fort lissage, le ressort traîne loin derrière
9283-
/// une souris qui clique dès l'arrivée, mais la pointe dessinée, `ctx.cursor` et `ctx.clicks`
9284-
/// tombent sur le même pixel pendant le contact.
9282+
/// Hyprland position-only sidecars have no bitmap assets or cursor type. The
9283+
/// renderer must still select OpenScreen's bundled arrow sprite, with its
9284+
/// configured hotspot, for both preview and export.
92859285
#[test]
9286-
fn the_flat_cursor_lands_on_the_click_under_smoothing() {
9287-
const TC: f32 = 1.5;
9286+
fn position_only_track_uses_the_bundled_arrow_sprite() {
92889287
let scene = model_scene();
92899288
let cfg = crate::config::all().pop().expect("cfg");
92909289
let fg = full_frame_geometry();
9291-
let raw = approach_track([0.08, 0.05], [0.7, 0.6], [0.2, 0.9], 2.0, 0.0, TC);
9292-
for smoothing in [0.0, 0.3, 0.67, 1.0] {
9293-
let track = raw.smoothed(smoothing);
9294-
for ms in [0, 40, 100] {
9295-
let input = cursor_input(&scene, &cfg, &track, TC + ms as f32 / 1000.0);
9296-
let fx = effect_pointer(&fg, &input);
9297-
let (cursor, click) = (fx.cursor.expect("pointe"), fx.clicks[0].0);
9298-
let drawn = plan_cursor(&fg, &input).expect("visible").placement.upright_center();
9299-
let off = |a: [f32; 2], b: [f32; 2]| ((a[0] - b[0]) * 1920.0).hypot((a[1] - b[1]) * 1080.0);
9300-
assert!(off(cursor, click) < 0.5, "lissage {smoothing}, {ms} ms : {} px", off(cursor, click));
9301-
assert!(off(drawn, click) < 0.5, "lissage {smoothing}, {ms} ms : {} px", off(drawn, click));
9302-
}
9303-
}
9290+
let track = still_track(vec![]);
9291+
let plan = plan_cursor(&fg, &cursor_input(&scene, &cfg, &track, 1.0)).expect("visible");
9292+
9293+
assert_eq!(plan.cursor_type, None, "position-only samples have no native shape");
9294+
assert!(plan.placement.upright_center()[0] > 0.0);
9295+
assert!(plan.placement.upright_center()[1] > 0.0);
93049296
}
93059297

93069298
/// Masqué par l'utilisateur, le curseur n'est plus dessiné, mais les effets gardent la pointe

‎electron/ipc/handlers.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ import {
102102
LinuxNativeCaptureSession,
103103
} from "../native-bridge/capture/linuxNativeCaptureSession";
104104
import { compactCursorTelemetryPauseRanges } from "../native-bridge/cursor/recording/cursorPauseRanges";
105+
import { HyprlandCursorTracker } from "../native-bridge/cursor/recording/hyprlandCursorTracker";
105106
import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory";
106107
import {
107108
isMacCursorHelperUnavailable,
@@ -2630,7 +2631,11 @@ export function registerIpcHandlers(
26302631

26312632
const session = new LinuxNativeCaptureSession({
26322633
outputPath,
2633-
cursorMode: portalCursorMode(cursorCaptureMode),
2634+
cursorMode: portalCursorMode(
2635+
cursorCaptureMode,
2636+
HyprlandCursorTracker.isAvailable(),
2637+
),
2638+
sampleIntervalMs: CURSOR_SAMPLE_INTERVAL_MS,
26342639
fps: request.video.fps,
26352640
...(request.video.bitrate ? { bitrate: request.video.bitrate } : {}),
26362641
audio: {
@@ -2716,7 +2721,11 @@ export function registerIpcHandlers(
27162721
prepared ??
27172722
new LinuxNativeCaptureSession({
27182723
outputPath,
2719-
cursorMode: portalCursorMode(cursorCaptureMode),
2724+
cursorMode: portalCursorMode(
2725+
cursorCaptureMode,
2726+
HyprlandCursorTracker.isAvailable(),
2727+
),
2728+
sampleIntervalMs: CURSOR_SAMPLE_INTERVAL_MS,
27202729
fps: request.video.fps,
27212730
...(request.video.bitrate ? { bitrate: request.video.bitrate } : {}),
27222731
audio: {
@@ -2822,7 +2831,11 @@ export function registerIpcHandlers(
28222831
// The helper collects cursor samples itself, from the same portal
28232832
// session that produced the pixels, so there is no separate sampler
28242833
// to stop and no clock offset to correct — the two are one recording.
2825-
if (cursorCaptureMode === "editable-overlay" && result.cursor.samples.length > 0) {
2834+
// Keep the sidecar even when a run produced zero samples. An empty
2835+
// sidecar is an explicit native-capture diagnostic; omitting it made a
2836+
// failed Hyprland IPC session indistinguishable from the Chromium
2837+
// fallback and hid the real cause of an invisible cursor.
2838+
if (cursorCaptureMode === "editable-overlay") {
28262839
await fs.writeFile(
28272840
`${result.path}.cursor.json`,
28282841
JSON.stringify(result.cursor, null, 2),

‎electron/native-bridge/capture/linuxNativeCaptureSession.ts‎

Lines changed: 106 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type CursorPauseRange,
66
compactCursorTelemetryPauseRanges,
77
} from "../cursor/recording/cursorPauseRanges";
8+
import { HyprlandCursorTracker } from "../cursor/recording/hyprlandCursorTracker";
89
import {
910
NdjsonLineReader,
1011
PipeWireCursorAccumulator,
@@ -56,7 +57,8 @@ const HELPER_OUTPUT_MAX_BYTES = 64 * 1024;
5657

5758
export interface LinuxCaptureConfig {
5859
outputPath: string;
59-
cursorMode: "metadata" | "embedded";
60+
cursorMode: "auto" | "metadata" | "embedded" | "hidden";
61+
sampleIntervalMs?: number;
6062
fps: number;
6163
bitrate?: number;
6264
audio: {
@@ -119,6 +121,9 @@ export class LinuxNativeCaptureSession {
119121
private lastError: string | null = null;
120122
private paused = false;
121123
private helperOutputText = "";
124+
private effectiveCursorMode: "metadata" | "embedded" | "hidden" = "metadata";
125+
private cursorMetadataSupported: boolean | null = null;
126+
private hyprlandCursor: HyprlandCursorTracker | null = null;
122127
private cursorRecordingStartMs = 0;
123128
private cursorPauseStartedAtMs: number | null = null;
124129
private cursorPauseRanges: CursorPauseRange[] = [];
@@ -317,6 +322,7 @@ export class LinuxNativeCaptureSession {
317322
if (this.paused) {
318323
return;
319324
}
325+
this.hyprlandCursor?.pause();
320326
this.write("pause");
321327
this.paused = true;
322328
this.cursorPauseStartedAtMs = Date.now();
@@ -326,6 +332,7 @@ export class LinuxNativeCaptureSession {
326332
if (!this.paused) {
327333
return;
328334
}
335+
this.hyprlandCursor?.resume();
329336
this.write("resume");
330337
this.completeCursorPauseRange();
331338
this.paused = false;
@@ -344,6 +351,11 @@ export class LinuxNativeCaptureSession {
344351
return this.paused;
345352
}
346353

354+
/** The cursor mode actually negotiated by the portal. */
355+
get cursorMode(): LinuxCaptureConfig["cursorMode"] {
356+
return this.negotiatedCursorMode ?? this.config.cursorMode;
357+
}
358+
347359
/**
348360
* What the portal granted. Known from `stream-started`, so it is populated by
349361
* the time [`waitUntilCapturing`] resolves.
@@ -356,15 +368,6 @@ export class LinuxNativeCaptureSession {
356368
return this.sourceKind;
357369
}
358370

359-
/**
360-
* The cursor mode the helper really negotiates, known once `start()` resolves.
361-
* A portal without EMBEDDED gets METADATA instead (#1357), so the cursor has
362-
* to be drawn by the editor even though the system cursor was asked for.
363-
*/
364-
get cursorMode(): LinuxCaptureConfig["cursorMode"] {
365-
return this.negotiatedCursorMode ?? this.config.cursorMode;
366-
}
367-
368371
/**
369372
* What the helper said in this session, for Save Diagnostics' `helperOutput.linux`:
370373
* its stderr as written and every stdout line except `cursor-sample`, which comes
@@ -449,6 +452,8 @@ export class LinuxNativeCaptureSession {
449452
}
450453

451454
private kill() {
455+
this.hyprlandCursor?.stop();
456+
this.hyprlandCursor = null;
452457
const child = this.process;
453458
if (!child || child.killed) {
454459
return;
@@ -480,12 +485,42 @@ export class LinuxNativeCaptureSession {
480485

481486
private handleEvent(payload: PipeWireHelperEvent) {
482487
switch (payload.event) {
483-
case "ready":
484-
if (payload.cursorMode === "metadata" || payload.cursorMode === "embedded") {
488+
case "ready": {
489+
if (
490+
payload.cursorMode === "metadata" ||
491+
payload.cursorMode === "embedded" ||
492+
payload.cursorMode === "hidden"
493+
) {
485494
this.negotiatedCursorMode = payload.cursorMode;
486495
}
496+
this.cursorMetadataSupported =
497+
typeof payload.cursorMetadataSupported === "boolean"
498+
? payload.cursorMetadataSupported
499+
: null;
500+
this.effectiveCursorMode =
501+
payload.cursorMode === "hidden"
502+
? "hidden"
503+
: payload.cursorMode === "embedded"
504+
? "embedded"
505+
: "metadata";
506+
const useHyprlandPositionTelemetry =
507+
this.config.cursorMode === "auto" &&
508+
(this.effectiveCursorMode !== "metadata" || this.cursorMetadataSupported === false);
509+
this.ensureHyprlandCursorTracker(
510+
useHyprlandPositionTelemetry || this.effectiveCursorMode === "hidden",
511+
);
512+
console.info(
513+
"[capture-linux] cursor negotiation",
514+
JSON.stringify({
515+
requested: this.config.cursorMode,
516+
effective: this.effectiveCursorMode,
517+
metadataSupported: payload.cursorMetadataSupported ?? null,
518+
hyprlandTracker: Boolean(this.hyprlandCursor),
519+
}),
520+
);
487521
this.resolveReady();
488522
return;
523+
}
489524

490525
case "source-selected":
491526
if (payload.sourceKind) {
@@ -519,6 +554,24 @@ export class LinuxNativeCaptureSession {
519554
sourceKind: payload.sourceKind ?? null,
520555
}),
521556
);
557+
// Some helper/portal combinations report the negotiated mode before
558+
// the stream details are available. Re-evaluate here so hidden-cursor
559+
// capture cannot silently lose its Hyprland position telemetry.
560+
this.ensureHyprlandCursorTracker(
561+
(this.config.cursorMode === "auto" &&
562+
(this.effectiveCursorMode !== "metadata" || this.cursorMetadataSupported === false)) ||
563+
this.effectiveCursorMode === "hidden",
564+
);
565+
if (this.hyprlandCursor && !this.hyprlandCursor.hasCaptureGeometry()) {
566+
this.hyprlandCursor.setCaptureGeometry({
567+
position:
568+
payload.positionX != null && payload.positionY != null
569+
? { x: payload.positionX, y: payload.positionY }
570+
: null,
571+
width: payload.logicalWidth ?? payload.width,
572+
height: payload.logicalHeight ?? payload.height,
573+
});
574+
}
522575
return;
523576

524577
case "audio-source":
@@ -555,6 +608,22 @@ export class LinuxNativeCaptureSession {
555608
// picker is dropped rather than left pinned to the start.
556609
this.cursor.rebase(payload.timestampMs);
557610
this.cursorRecordingStartMs = payload.timestampMs;
611+
this.ensureHyprlandCursorTracker(
612+
(this.config.cursorMode === "auto" &&
613+
(this.effectiveCursorMode !== "metadata" || this.cursorMetadataSupported === false)) ||
614+
this.effectiveCursorMode === "hidden",
615+
);
616+
this.hyprlandCursor?.rebase();
617+
// Older helper builds and some portal backends may omit stream-started
618+
// geometry. The encoded frame dimensions are still sufficient for the
619+
// monitor-size/origin fallback in HyprlandCursorTracker.
620+
if (this.hyprlandCursor && !this.hyprlandCursor.hasCaptureGeometry()) {
621+
this.hyprlandCursor.setCaptureGeometry({
622+
position: null,
623+
width: payload.width,
624+
height: payload.height,
625+
});
626+
}
558627
this.capturing = true;
559628
console.info(
560629
"[capture-linux] capture started",
@@ -566,19 +635,20 @@ export class LinuxNativeCaptureSession {
566635
return;
567636

568637
case "cursor-sample":
569-
this.cursor.addSample(payload);
638+
if (this.effectiveCursorMode === "metadata") this.cursor.addSample(payload);
570639
return;
571640

572-
case "capture-stopped":
641+
case "capture-stopped": {
642+
const cursor =
643+
this.hyprlandCursor?.stop() ??
644+
compactCursorTelemetryPauseRanges(this.cursor.toRecordingData(), this.cursorPauseRanges);
645+
this.hyprlandCursor = null;
573646
this.stopped = {
574647
path: payload.path,
575648
durationMs: payload.durationMs,
576649
frames: payload.frames,
577650
droppedFrames: payload.dropped,
578-
cursor: compactCursorTelemetryPauseRanges(
579-
this.cursor.toRecordingData(),
580-
this.cursorPauseRanges,
581-
),
651+
cursor,
582652
...(this.sourceKind ? { sourceKind: this.sourceKind } : {}),
583653
...(this.videoEncoder ? { videoEncoder: this.videoEncoder } : {}),
584654
};
@@ -591,9 +661,12 @@ export class LinuxNativeCaptureSession {
591661
convertMs: payload.convertMs,
592662
uploadMs: payload.uploadMs,
593663
encodeMs: payload.encodeMs,
664+
cursorProvider: cursor.provider,
665+
cursorSamples: cursor.samples.length,
594666
}),
595667
);
596668
return;
669+
}
597670

598671
case "warning":
599672
console.warn(`[capture-linux] ${payload.code}: ${payload.message}`);
@@ -616,6 +689,21 @@ export class LinuxNativeCaptureSession {
616689
}
617690
}
618691

692+
private ensureHyprlandCursorTracker(needed: boolean) {
693+
if (!needed || this.hyprlandCursor) return;
694+
if (!HyprlandCursorTracker.isAvailable()) {
695+
console.warn(
696+
"[capture-linux] position-only cursor capture requested, but the Hyprland IPC socket is unavailable",
697+
);
698+
return;
699+
}
700+
this.hyprlandCursor = new HyprlandCursorTracker({
701+
sampleIntervalMs: this.config.sampleIntervalMs ?? 33,
702+
maxSamples: this.config.maxCursorSamples,
703+
});
704+
this.hyprlandCursor.start();
705+
}
706+
619707
private resolveReady() {
620708
const resolve = this.readyResolve;
621709
this.clearReadyState();

0 commit comments

Comments
 (0)