Skip to content
This repository was archived by the owner on Jun 17, 2026. It is now read-only.

Commit b4ce8a1

Browse files
committed
feat: auto-zoom on clicks + exclude OpenScreen's own windows from display capture
Cursor telemetry already records click events (interactionType), but the suggestion algorithm only looks at cursor dwell. This wires clicks through to the suggestion pipeline so the "Suggest Zooms from Cursor" button can produce zooms where the user actually clicked, not just where the cursor paused. - Pass interactionType through readCursorTelemetryFile (was stripped on load). - Add detectZoomClickCandidates with 700ms clustering for double/triple clicks. - detectZoomCandidates combines click + dwell with clicks ranked stronger. - TimelineEditor switches to the combined detector. Separately, full-screen recordings include the OpenScreen HUD because the helper passes excludingWindows: []. Allow the renderer to pass its own pid through the request and have the SCK helper exclude any windows owned by that process / bundle identifier from the SCContentFilter. Tests: add zoomSuggestionUtils.test.ts (5 cases).
1 parent 9f7f498 commit b4ce8a1

7 files changed

Lines changed: 157 additions & 4 deletions

File tree

electron/ipc/handlers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,7 @@ async function readCursorTelemetryFile(targetVideoPath: string) {
516516
timeMs: sample.timeMs,
517517
cx: sample.cx,
518518
cy: sample.cy,
519+
...(sample.interactionType ? { interactionType: sample.interactionType } : {}),
519520
})),
520521
};
521522
} catch (error) {
@@ -1686,6 +1687,8 @@ export function registerIpcHandlers(
16861687
null)
16871688
: getSelectedDisplay();
16881689
const bounds = request.source.bounds ?? sourceDisplay?.bounds ?? getSelectedSourceBounds();
1690+
const excludedApps =
1691+
request.source.type === "display" ? [{ processID: process.pid }] : undefined;
16891692
const config: NativeMacRecordingRequest = {
16901693
...request,
16911694
schemaVersion: 1,
@@ -1712,6 +1715,7 @@ export function registerIpcHandlers(
17121715
`${RECORDING_FILE_PREFIX}${recordingId}${RECORDING_SESSION_SUFFIX}`,
17131716
),
17141717
},
1718+
excludedApps,
17151719
};
17161720

17171721
console.info("[native-sck] starting macOS capture", {

electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ struct RecordingRequest: Decodable {
6262
let manifestPath: String?
6363
}
6464

65+
struct ExcludedApp: Decodable {
66+
let bundleIdentifier: String?
67+
let processID: Int32?
68+
}
69+
6570
let schemaVersion: Int?
6671
let recordingId: Int?
6772
let source: Source
@@ -70,6 +75,7 @@ struct RecordingRequest: Decodable {
7075
let webcam: Webcam
7176
let cursor: Cursor
7277
let outputs: Outputs
78+
let excludedApps: [ExcludedApp]?
7379
}
7480

7581
enum HelperError: Error, CustomStringConvertible {
@@ -348,8 +354,25 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
348354
}
349355
let width = Int(CGDisplayPixelsWide(display.displayID))
350356
let height = Int(CGDisplayPixelsHigh(display.displayID))
357+
let requestedExclusions = request.excludedApps ?? []
358+
let excludedBundleIdentifiers = Set(
359+
requestedExclusions.compactMap { $0.bundleIdentifier }
360+
)
361+
let excludedProcessIDs = Set(
362+
requestedExclusions.compactMap { $0.processID }
363+
)
364+
let excludedWindows = content.windows.filter { window in
365+
guard let owner = window.owningApplication else { return false }
366+
if excludedBundleIdentifiers.contains(owner.bundleIdentifier) {
367+
return true
368+
}
369+
if excludedProcessIDs.contains(owner.processID) {
370+
return true
371+
}
372+
return false
373+
}
351374
return CaptureTarget(
352-
filter: SCContentFilter(display: display, excludingWindows: []),
375+
filter: SCContentFilter(display: display, excludingWindows: excludedWindows),
353376
width: clampCaptureDimension(width, fallback: request.video.width),
354377
height: clampCaptureDimension(height, fallback: request.video.height)
355378
)

src/components/video-editor/timeline/TimelineEditor.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import Item from "./Item";
3838
import KeyframeMarkers from "./KeyframeMarkers";
3939
import Row from "./Row";
4040
import TimelineWrapper from "./TimelineWrapper";
41-
import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils";
41+
import { detectZoomCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils";
4242

4343
const ZOOM_ROW_ID = "row-zoom";
4444
const TRIM_ROW_ID = "row-trim";
@@ -1157,7 +1157,7 @@ export default function TimelineEditor({
11571157
return;
11581158
}
11591159

1160-
const dwellCandidates = detectZoomDwellCandidates(normalizedSamples);
1160+
const dwellCandidates = detectZoomCandidates(normalizedSamples);
11611161

11621162
if (dwellCandidates.length === 0) {
11631163
toast.info(t("errors.noDwellMoments"), {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { CursorTelemetryPoint } from "../types";
3+
import { detectZoomCandidates, detectZoomClickCandidates } from "./zoomSuggestionUtils";
4+
5+
describe("detectZoomClickCandidates", () => {
6+
it("returns no candidates when there are no click samples", () => {
7+
const samples: CursorTelemetryPoint[] = [
8+
{ timeMs: 0, cx: 0.1, cy: 0.1, interactionType: "move" },
9+
{ timeMs: 100, cx: 0.2, cy: 0.2, interactionType: "move" },
10+
];
11+
expect(detectZoomClickCandidates(samples)).toEqual([]);
12+
});
13+
14+
it("creates one candidate per isolated click", () => {
15+
const samples: CursorTelemetryPoint[] = [
16+
{ timeMs: 1000, cx: 0.3, cy: 0.4, interactionType: "click" },
17+
{ timeMs: 5000, cx: 0.7, cy: 0.8, interactionType: "click" },
18+
];
19+
const candidates = detectZoomClickCandidates(samples);
20+
expect(candidates).toHaveLength(2);
21+
expect(candidates[0].focus).toEqual({ cx: 0.3, cy: 0.4 });
22+
expect(candidates[1].focus).toEqual({ cx: 0.7, cy: 0.8 });
23+
expect(candidates[0].source).toBe("click");
24+
});
25+
26+
it("clusters rapid successive clicks (double-click) into a single candidate", () => {
27+
const samples: CursorTelemetryPoint[] = [
28+
{ timeMs: 1000, cx: 0.5, cy: 0.5, interactionType: "click" },
29+
{ timeMs: 1200, cx: 0.5, cy: 0.5, interactionType: "click" },
30+
{ timeMs: 1400, cx: 0.5, cy: 0.5, interactionType: "click" },
31+
];
32+
const candidates = detectZoomClickCandidates(samples);
33+
expect(candidates).toHaveLength(1);
34+
expect(candidates[0].centerTimeMs).toBe(1200);
35+
});
36+
37+
it("treats double-click and right-click as click interactions", () => {
38+
const samples: CursorTelemetryPoint[] = [
39+
{ timeMs: 1000, cx: 0.2, cy: 0.2, interactionType: "double-click" },
40+
{ timeMs: 5000, cx: 0.8, cy: 0.8, interactionType: "right-click" },
41+
];
42+
expect(detectZoomClickCandidates(samples)).toHaveLength(2);
43+
});
44+
});
45+
46+
describe("detectZoomCandidates", () => {
47+
it("returns click candidates ahead of dwell candidates", () => {
48+
const samples: CursorTelemetryPoint[] = [
49+
{ timeMs: 0, cx: 0.1, cy: 0.1, interactionType: "move" },
50+
{ timeMs: 500, cx: 0.1, cy: 0.1, interactionType: "move" },
51+
{ timeMs: 1000, cx: 0.1, cy: 0.1, interactionType: "move" },
52+
{ timeMs: 2000, cx: 0.9, cy: 0.9, interactionType: "click" },
53+
];
54+
const candidates = detectZoomCandidates(samples);
55+
const clickIndex = candidates.findIndex((c) => c.source === "click");
56+
const dwellIndex = candidates.findIndex((c) => c.source === "dwell");
57+
expect(clickIndex).toBeGreaterThanOrEqual(0);
58+
expect(dwellIndex).toBeGreaterThanOrEqual(0);
59+
expect(clickIndex).toBeLessThan(dwellIndex);
60+
});
61+
});

src/components/video-editor/timeline/zoomSuggestionUtils.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@ export const MIN_DWELL_DURATION_MS = 450;
44
export const MAX_DWELL_DURATION_MS = 2600;
55
export const DWELL_MOVE_THRESHOLD = 0.02;
66

7+
export const CLICK_CLUSTER_WINDOW_MS = 700;
8+
export const CLICK_STRENGTH_BASE_MS = 3000;
9+
export const CLICK_STRENGTH_PER_EVENT_MS = 600;
10+
711
export interface ZoomDwellCandidate {
812
centerTimeMs: number;
913
focus: ZoomFocus;
1014
strength: number;
15+
source?: "dwell" | "click";
1116
}
1217

1318
function normalizeTelemetrySample(
@@ -77,5 +82,60 @@ export function detectZoomDwellCandidates(samples: CursorTelemetryPoint[]): Zoom
7782
}
7883
pushRunIfDwell(runStart, samples.length);
7984

80-
return dwellCandidates;
85+
return dwellCandidates.map((candidate) => ({ ...candidate, source: "dwell" as const }));
86+
}
87+
88+
const CLICK_INTERACTIONS = new Set(["click", "double-click", "right-click", "middle-click"]);
89+
90+
export function detectZoomClickCandidates(samples: CursorTelemetryPoint[]): ZoomDwellCandidate[] {
91+
if (samples.length === 0) {
92+
return [];
93+
}
94+
95+
const clickSamples = samples.filter(
96+
(sample) => sample.interactionType && CLICK_INTERACTIONS.has(sample.interactionType),
97+
);
98+
99+
if (clickSamples.length === 0) {
100+
return [];
101+
}
102+
103+
const clusters: CursorTelemetryPoint[][] = [];
104+
let currentCluster: CursorTelemetryPoint[] = [];
105+
106+
for (const click of clickSamples) {
107+
if (currentCluster.length === 0) {
108+
currentCluster.push(click);
109+
continue;
110+
}
111+
const lastClick = currentCluster[currentCluster.length - 1];
112+
if (click.timeMs - lastClick.timeMs <= CLICK_CLUSTER_WINDOW_MS) {
113+
currentCluster.push(click);
114+
} else {
115+
clusters.push(currentCluster);
116+
currentCluster = [click];
117+
}
118+
}
119+
if (currentCluster.length > 0) {
120+
clusters.push(currentCluster);
121+
}
122+
123+
return clusters.map((cluster) => {
124+
const centerTimeMs = Math.round(cluster.reduce((sum, c) => sum + c.timeMs, 0) / cluster.length);
125+
const avgCx = cluster.reduce((sum, c) => sum + c.cx, 0) / cluster.length;
126+
const avgCy = cluster.reduce((sum, c) => sum + c.cy, 0) / cluster.length;
127+
const strength = CLICK_STRENGTH_BASE_MS + cluster.length * CLICK_STRENGTH_PER_EVENT_MS;
128+
return {
129+
centerTimeMs,
130+
focus: { cx: avgCx, cy: avgCy },
131+
strength,
132+
source: "click" as const,
133+
};
134+
});
135+
}
136+
137+
export function detectZoomCandidates(samples: CursorTelemetryPoint[]): ZoomDwellCandidate[] {
138+
const clickCandidates = detectZoomClickCandidates(samples);
139+
const dwellCandidates = detectZoomDwellCandidates(samples);
140+
return [...clickCandidates, ...dwellCandidates];
81141
}

src/lib/nativeMacRecording.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ export type NativeMacRecordingRequest = {
4646
screenPath: string;
4747
manifestPath?: string;
4848
};
49+
excludedApps?: Array<{
50+
bundleIdentifier?: string;
51+
processID?: number;
52+
}>;
4953
};
5054

5155
export type NativeMacHelperReadyEvent = {

src/native/contracts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface CursorTelemetryPoint {
2525
timeMs: number;
2626
cx: number;
2727
cy: number;
28+
interactionType?: "move" | "click" | "double-click" | "right-click" | "middle-click" | "mouseup";
2829
}
2930

3031
export interface CursorRecordingSample extends CursorTelemetryPoint {

0 commit comments

Comments
 (0)