Repository navigation
feat(linux): add Hyprland cursor tracking and position zoom #1380
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d77b62b
ef89c46
9bfa00b
8b7e3d5
d3c6dfe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ import { | |
| type CursorPauseRange, | ||
| compactCursorTelemetryPauseRanges, | ||
| } from "../cursor/recording/cursorPauseRanges"; | ||
| import { HyprlandCursorTracker } from "../cursor/recording/hyprlandCursorTracker"; | ||
| import { | ||
| NdjsonLineReader, | ||
| PipeWireCursorAccumulator, | ||
|
|
@@ -56,7 +57,8 @@ const HELPER_OUTPUT_MAX_BYTES = 64 * 1024; | |
|
|
||
| export interface LinuxCaptureConfig { | ||
| outputPath: string; | ||
| cursorMode: "metadata" | "embedded"; | ||
| cursorMode: "auto" | "metadata" | "embedded" | "hidden"; | ||
| sampleIntervalMs?: number; | ||
| fps: number; | ||
| bitrate?: number; | ||
| audio: { | ||
|
|
@@ -119,6 +121,9 @@ export class LinuxNativeCaptureSession { | |
| private lastError: string | null = null; | ||
| private paused = false; | ||
| private helperOutputText = ""; | ||
| private effectiveCursorMode: "metadata" | "embedded" | "hidden" = "metadata"; | ||
| private cursorMetadataSupported: boolean | null = null; | ||
| private hyprlandCursor: HyprlandCursorTracker | null = null; | ||
| private cursorRecordingStartMs = 0; | ||
| private cursorPauseStartedAtMs: number | null = null; | ||
| private cursorPauseRanges: CursorPauseRange[] = []; | ||
|
|
@@ -175,6 +180,7 @@ export class LinuxNativeCaptureSession { | |
| }); | ||
| child.once("exit", (code, signal) => { | ||
| this.process = null; | ||
| if (!this.stopped) this.stopHyprlandCursorTracker(); | ||
| const reason = | ||
| this.lastError ?? | ||
| `the Linux capture helper exited (code=${code ?? "null"}, signal=${signal ?? "null"})`; | ||
|
|
@@ -201,6 +207,7 @@ export class LinuxNativeCaptureSession { | |
| }); | ||
| child.once("error", (error) => { | ||
| this.process = null; | ||
| this.stopHyprlandCursorTracker(); | ||
| this.rejectReady(error); | ||
| }); | ||
|
|
||
|
|
@@ -317,6 +324,7 @@ export class LinuxNativeCaptureSession { | |
| if (this.paused) { | ||
| return; | ||
| } | ||
| this.hyprlandCursor?.pause(); | ||
| this.write("pause"); | ||
| this.paused = true; | ||
| this.cursorPauseStartedAtMs = Date.now(); | ||
|
|
@@ -326,6 +334,7 @@ export class LinuxNativeCaptureSession { | |
| if (!this.paused) { | ||
| return; | ||
| } | ||
| this.hyprlandCursor?.resume(); | ||
| this.write("resume"); | ||
| this.completeCursorPauseRange(); | ||
| this.paused = false; | ||
|
|
@@ -344,6 +353,11 @@ export class LinuxNativeCaptureSession { | |
| return this.paused; | ||
| } | ||
|
|
||
| /** The cursor mode actually negotiated by the portal. */ | ||
| get cursorMode(): LinuxCaptureConfig["cursorMode"] { | ||
| return this.negotiatedCursorMode ?? this.config.cursorMode; | ||
| } | ||
|
|
||
| /** | ||
| * What the portal granted. Known from `stream-started`, so it is populated by | ||
| * the time [`waitUntilCapturing`] resolves. | ||
|
|
@@ -356,15 +370,6 @@ export class LinuxNativeCaptureSession { | |
| return this.sourceKind; | ||
| } | ||
|
|
||
| /** | ||
| * The cursor mode the helper really negotiates, known once `start()` resolves. | ||
| * A portal without EMBEDDED gets METADATA instead (#1357), so the cursor has | ||
| * to be drawn by the editor even though the system cursor was asked for. | ||
| */ | ||
| get cursorMode(): LinuxCaptureConfig["cursorMode"] { | ||
| return this.negotiatedCursorMode ?? this.config.cursorMode; | ||
| } | ||
|
|
||
| /** | ||
| * What the helper said in this session, for Save Diagnostics' `helperOutput.linux`: | ||
| * its stderr as written and every stdout line except `cursor-sample`, which comes | ||
|
|
@@ -449,6 +454,8 @@ export class LinuxNativeCaptureSession { | |
| } | ||
|
|
||
| private kill() { | ||
| this.hyprlandCursor?.stop(); | ||
| this.hyprlandCursor = null; | ||
| const child = this.process; | ||
| if (!child || child.killed) { | ||
| return; | ||
|
|
@@ -480,12 +487,42 @@ export class LinuxNativeCaptureSession { | |
|
|
||
| private handleEvent(payload: PipeWireHelperEvent) { | ||
| switch (payload.event) { | ||
| case "ready": | ||
| if (payload.cursorMode === "metadata" || payload.cursorMode === "embedded") { | ||
| case "ready": { | ||
| if ( | ||
| payload.cursorMode === "metadata" || | ||
| payload.cursorMode === "embedded" || | ||
| payload.cursorMode === "hidden" | ||
| ) { | ||
| this.negotiatedCursorMode = payload.cursorMode; | ||
| } | ||
| this.cursorMetadataSupported = | ||
| typeof payload.cursorMetadataSupported === "boolean" | ||
| ? payload.cursorMetadataSupported | ||
| : null; | ||
| this.effectiveCursorMode = | ||
| payload.cursorMode === "hidden" | ||
| ? "hidden" | ||
| : payload.cursorMode === "embedded" | ||
| ? "embedded" | ||
| : "metadata"; | ||
| const useHyprlandPositionTelemetry = | ||
| this.config.cursorMode === "auto" && | ||
| (this.effectiveCursorMode !== "metadata" || this.cursorMetadataSupported === false); | ||
| this.ensureHyprlandCursorTracker( | ||
| useHyprlandPositionTelemetry || this.effectiveCursorMode === "hidden", | ||
| ); | ||
| console.info( | ||
| "[capture-linux] cursor negotiation", | ||
| JSON.stringify({ | ||
| requested: this.config.cursorMode, | ||
| effective: this.effectiveCursorMode, | ||
| metadataSupported: payload.cursorMetadataSupported ?? null, | ||
| hyprlandTracker: Boolean(this.hyprlandCursor), | ||
| }), | ||
| ); | ||
| this.resolveReady(); | ||
| return; | ||
| } | ||
|
|
||
| case "source-selected": | ||
| if (payload.sourceKind) { | ||
|
|
@@ -519,6 +556,25 @@ export class LinuxNativeCaptureSession { | |
| sourceKind: payload.sourceKind ?? null, | ||
| }), | ||
| ); | ||
| // Some helper/portal combinations report the negotiated mode before | ||
| // the stream details are available. Re-evaluate here so hidden-cursor | ||
| // capture cannot silently lose its Hyprland position telemetry. | ||
| this.ensureHyprlandCursorTracker( | ||
| (this.config.cursorMode === "auto" && | ||
| (this.effectiveCursorMode !== "metadata" || this.cursorMetadataSupported === false)) || | ||
| this.effectiveCursorMode === "hidden", | ||
| ); | ||
| if (this.hyprlandCursor && !this.hyprlandCursor.hasCaptureGeometry()) { | ||
| this.hyprlandCursor.setCaptureGeometry({ | ||
| position: | ||
| payload.positionX != null && payload.positionY != null | ||
| ? { x: payload.positionX, y: payload.positionY } | ||
| : null, | ||
| width: payload.logicalWidth ?? payload.width, | ||
| height: payload.logicalHeight ?? payload.height, | ||
| allowMonitorOriginFallback: payload.sourceKind !== "window", | ||
| }); | ||
| } | ||
| return; | ||
|
|
||
| case "audio-source": | ||
|
|
@@ -555,6 +611,23 @@ export class LinuxNativeCaptureSession { | |
| // picker is dropped rather than left pinned to the start. | ||
| this.cursor.rebase(payload.timestampMs); | ||
| this.cursorRecordingStartMs = payload.timestampMs; | ||
| this.ensureHyprlandCursorTracker( | ||
| (this.config.cursorMode === "auto" && | ||
| (this.effectiveCursorMode !== "metadata" || this.cursorMetadataSupported === false)) || | ||
| this.effectiveCursorMode === "hidden", | ||
| ); | ||
| this.hyprlandCursor?.rebase(); | ||
| // Older helper builds and some portal backends may omit stream-started | ||
| // geometry. The encoded frame dimensions are still sufficient for the | ||
| // monitor-size/origin fallback in HyprlandCursorTracker. | ||
| if (this.hyprlandCursor && !this.hyprlandCursor.hasCaptureGeometry()) { | ||
| this.hyprlandCursor.setCaptureGeometry({ | ||
| position: null, | ||
| width: payload.width, | ||
| height: payload.height, | ||
| allowMonitorOriginFallback: this.sourceKind !== "window", | ||
| }); | ||
| } | ||
| this.capturing = true; | ||
| console.info( | ||
| "[capture-linux] capture started", | ||
|
|
@@ -566,19 +639,20 @@ export class LinuxNativeCaptureSession { | |
| return; | ||
|
|
||
| case "cursor-sample": | ||
| this.cursor.addSample(payload); | ||
| if (this.effectiveCursorMode === "metadata") this.cursor.addSample(payload); | ||
| return; | ||
|
|
||
| case "capture-stopped": | ||
| case "capture-stopped": { | ||
| const cursor = | ||
| this.hyprlandCursor?.stop() ?? | ||
| compactCursorTelemetryPauseRanges(this.cursor.toRecordingData(), this.cursorPauseRanges); | ||
| this.hyprlandCursor = null; | ||
| this.stopped = { | ||
| path: payload.path, | ||
| durationMs: payload.durationMs, | ||
| frames: payload.frames, | ||
| droppedFrames: payload.dropped, | ||
| cursor: compactCursorTelemetryPauseRanges( | ||
| this.cursor.toRecordingData(), | ||
| this.cursorPauseRanges, | ||
| ), | ||
| cursor, | ||
| ...(this.sourceKind ? { sourceKind: this.sourceKind } : {}), | ||
| ...(this.videoEncoder ? { videoEncoder: this.videoEncoder } : {}), | ||
| }; | ||
|
|
@@ -591,9 +665,12 @@ export class LinuxNativeCaptureSession { | |
| convertMs: payload.convertMs, | ||
| uploadMs: payload.uploadMs, | ||
| encodeMs: payload.encodeMs, | ||
| cursorProvider: cursor.provider, | ||
| cursorSamples: cursor.samples.length, | ||
| }), | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| case "warning": | ||
| console.warn(`[capture-linux] ${payload.code}: ${payload.message}`); | ||
|
|
@@ -616,6 +693,26 @@ export class LinuxNativeCaptureSession { | |
| } | ||
| } | ||
|
|
||
| private ensureHyprlandCursorTracker(needed: boolean) { | ||
| if (!needed || this.hyprlandCursor) return; | ||
| if (!HyprlandCursorTracker.isAvailable()) { | ||
| console.warn( | ||
| "[capture-linux] position-only cursor capture requested, but the Hyprland IPC socket is unavailable", | ||
| ); | ||
| return; | ||
| } | ||
| this.hyprlandCursor = new HyprlandCursorTracker({ | ||
| sampleIntervalMs: this.config.sampleIntervalMs ?? 33, | ||
| maxSamples: this.config.maxCursorSamples, | ||
| }); | ||
| this.hyprlandCursor.start(); | ||
|
Comment on lines
+704
to
+708
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The tracker outlives the helper process if the helper exits on its own.
If the helper exits without In the prepare path, Stop and clear the tracker in the child.once("exit", (code, signal) => {
this.process = null;
if (!this.stopped) {
this.hyprlandCursor?.stop();
this.hyprlandCursor = null;
}
// ...existing handling
});
|
||
| } | ||
|
|
||
| private stopHyprlandCursorTracker() { | ||
| this.hyprlandCursor?.stop(); | ||
| this.hyprlandCursor = null; | ||
| } | ||
|
|
||
| private resolveReady() { | ||
| const resolve = this.readyResolve; | ||
| this.clearReadyState(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Resolve window origin before recording window cursor positions.
When the user selects a window, the portal does not provide
Stream.position. This code passesposition: null, andHyprlandCursorTracker.samplesubstitutes the containing monitor’s origin. For a window away from that origin, the saved positions are monitor-relative rather than window-relative. The cursor overlay and position-based zoom can therefore target the wrong place throughout the recording. Obtain the selected window’s origin before mapping its cursor positions; monitor origin is not a window-origin fallback. (github.com)