Skip to content

Commit e4782b1

Browse files
committed
fix: correct cursor coordinates for window, region, and secondary-display capture
The mouse tracker mapped global cursor points into recorded-file pixels with scale = pixelSize / displayBounds and no origin subtraction — and the session always passed full-display bounds with the *content's* pixel size. For window capture that's the wrong ratio (window pixels over display points) AND ignores where the window sits on screen, so the synthetic cursor landed far from the real pointer in every window recording. Only full-primary-display capture happened to map correctly. - Capture now publishes a single CaptureGeometry-style rect: the content's global rect in points, top-left origin (the CGEvent space) — the filter's contentRect for window capture, display.frame for display capture (a secondary display's origin is not (0,0)), and the offset region rect for region capture. Mapping is (global − origin) × pixels-per-point, per axis. - The seed sample now converts Cocoa→CG coordinates using the *primary* screen's height; it used NSScreen.main (the key window's screen), which corrupted the t=0 sample on multi-display setups. Tap events use CGEvent's native top-left coords directly instead of a flip-and-flip-back round trip. - The regionOffsetPixels plumbing is folded into the content rect, and CaptureInfo.displayBounds (whose value was wrong for anything but full-display capture) is replaced by contentRectPoints. Validation note: display recordings were previously validated correct against real footage and keep identical math (primary display origin is (0,0)); the window/secondary-display paths should be spot-checked with the ffmpeg frame-extraction method on a real window recording.
1 parent e2f2bdb commit e4782b1

3 files changed

Lines changed: 64 additions & 41 deletions

File tree

CineScreen/Capture/MouseTrackingService.swift

Lines changed: 37 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,16 @@ enum MouseTrackingError: LocalizedError {
3636

3737
@MainActor
3838
final class MouseTrackingService {
39-
/// Display bounds in points; used to map global-coord mouse positions
40-
/// onto the recorded image.
41-
private var displayBoundsPoints: CGRect = .zero
42-
/// Pixel size of the recorded image (output frame).
39+
/// The captured content's rect in global screen points (top-left origin,
40+
/// CGEvent space). Mouse points map into file pixels relative to this —
41+
/// origin subtraction handles windows/regions/secondary displays that
42+
/// don't sit at the global origin.
43+
private var contentRectPoints: CGRect = .zero
44+
/// Pixel size of the recorded image (output frame, after any downscale).
4345
private var pixelSize: CGSize = .zero
44-
/// scaleFactor = pixelSize / displayBounds — cached for the hot path.
45-
private var scale: CGFloat = 2.0
46-
/// If a region was captured, this offset is subtracted (in pixels)
47-
/// before clamping to pixelSize.
48-
private var regionOffsetPixels: CGPoint = .zero
46+
/// pixels-per-point, cached per axis for the hot path.
47+
private var scaleX: CGFloat = 2.0
48+
private var scaleY: CGFloat = 2.0
4949

5050
private var startTime: CFAbsoluteTime = 0
5151
private var tap: CFMachPort?
@@ -62,18 +62,20 @@ final class MouseTrackingService {
6262

6363
// MARK: - Lifecycle
6464

65-
func start(displayBoundsPoints: CGRect, pixelSize: CGSize, regionOffsetPixels: CGPoint = .zero) throws {
65+
func start(contentRectPoints: CGRect, pixelSize: CGSize) throws {
6666
guard !isTracking else { return }
6767
guard AXIsProcessTrusted() else {
6868
throw MouseTrackingError.accessibilityNotGranted
6969
}
7070

71-
self.displayBoundsPoints = displayBoundsPoints
71+
self.contentRectPoints = contentRectPoints
7272
self.pixelSize = pixelSize
73-
self.scale = displayBoundsPoints.width > 0
74-
? CGFloat(pixelSize.width) / displayBoundsPoints.width
73+
self.scaleX = contentRectPoints.width > 0
74+
? pixelSize.width / contentRectPoints.width
7575
: 2.0
76-
self.regionOffsetPixels = regionOffsetPixels
76+
self.scaleY = contentRectPoints.height > 0
77+
? pixelSize.height / contentRectPoints.height
78+
: scaleX
7779
self.samples = []
7880
self.startTime = CFAbsoluteTimeGetCurrent()
7981
// Build the cursor lookup AFTER NSApp is fully up — see comment on
@@ -116,9 +118,16 @@ final class MouseTrackingService {
116118
self.runLoopSource = source
117119
self.isTracking = true
118120

119-
// Seed with the current cursor position so the timeline always has a t=0 sample.
120-
let now = NSEvent.mouseLocation
121-
record(point: now, kind: .move)
121+
// Seed with the current cursor position so the timeline always has a
122+
// t=0 sample. NSEvent.mouseLocation is Cocoa space (bottom-left of
123+
// the PRIMARY screen) — convert once into the CG top-left global
124+
// space every other sample arrives in. The old code flipped with
125+
// NSScreen.main (the key window's screen), which is wrong whenever
126+
// that isn't the primary display.
127+
let cocoa = NSEvent.mouseLocation
128+
let primaryHeight = NSScreen.screens.first(where: { $0.frame.origin == .zero })?.frame.height
129+
?? NSScreen.screens.first?.frame.height ?? 0
130+
record(topLeftGlobalPoint: CGPoint(x: cocoa.x, y: primaryHeight - cocoa.y), kind: .move)
122131

123132
Log.mouse.info("Mouse tracking started")
124133
}
@@ -158,29 +167,24 @@ final class MouseTrackingService {
158167
default: kind = .move
159168
}
160169

161-
// Hop to main to mutate.
170+
// Hop to main to mutate. CGEvent.location is already top-left-origin
171+
// global points — the exact space `record` expects; the old
172+
// flip-to-Cocoa-and-back round trip (via the key window's screen
173+
// height) has been dropped.
162174
DispatchQueue.main.async { [self] in
163-
// CGEvent.location is already top-left origin in *global* coords (points).
164-
// Convert to bottom-left coordinates (NSEvent style) so we share the
165-
// same path as the seed sample.
166-
let screenHeight = NSScreen.main?.frame.height ?? 0
167-
let point = CGPoint(x: location.x, y: screenHeight - location.y)
168-
self.record(point: point, kind: kind)
175+
self.record(topLeftGlobalPoint: location, kind: kind)
169176
}
170177
}
171178

172-
private func record(point pointInScreenPoints: CGPoint, kind: MouseSample.Kind) {
179+
private func record(topLeftGlobalPoint point: CGPoint, kind: MouseSample.Kind) {
173180
let now = CFAbsoluteTimeGetCurrent()
174181
let elapsedMs = (now - startTime) * 1000.0
175182

176-
// Convert NSEvent-style (origin bottom-left) to top-left point coords.
177-
let screenHeight = NSScreen.main?.frame.height ?? 0
178-
let topLeftPoint = CGPoint(x: pointInScreenPoints.x, y: screenHeight - pointInScreenPoints.y)
179-
180-
// Clip out events that landed outside the recorded display, but record
181-
// them anyway — the editor decides what to do.
182-
let pixelX = (topLeftPoint.x * scale) - regionOffsetPixels.x
183-
let pixelY = (topLeftPoint.y * scale) - regionOffsetPixels.y
183+
// Map global points → recorded-file pixels: subtract the content
184+
// origin, then scale points→pixels per axis. Events outside the
185+
// captured content clamp to the edge — the editor decides what to do.
186+
let pixelX = (point.x - contentRectPoints.minX) * scaleX
187+
let pixelY = (point.y - contentRectPoints.minY) * scaleY
184188

185189
let clampedX = min(max(pixelX, 0), pixelSize.width)
186190
let clampedY = min(max(pixelY, 0), pixelSize.height)

CineScreen/Capture/RecordingSession.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,10 @@ final class RecordingSession {
9494
throw SessionError.captureFailed(error.localizedDescription)
9595
}
9696

97-
let regionOffset: CGPoint = request.region?.origin ?? .zero
9897
do {
9998
try mouse.start(
100-
displayBoundsPoints: info.displayBounds,
101-
pixelSize: CGSize(width: info.pixelWidth, height: info.pixelHeight),
102-
regionOffsetPixels: regionOffset
99+
contentRectPoints: info.contentRectPoints,
100+
pixelSize: CGSize(width: info.pixelWidth, height: info.pixelHeight)
103101
)
104102
} catch {
105103
Log.session.error("mouse.start failed: \(error.localizedDescription)")

CineScreen/Capture/ScreenCaptureService.swift

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,12 @@ struct CaptureInfo {
4545
var pixelWidth: Int
4646
var pixelHeight: Int
4747
var fps: Int
48-
var displayBounds: CGRect // in points
48+
/// Where the captured content lives on the desktop — global screen
49+
/// points, top-left origin (CGEvent space). The mouse tracker maps
50+
/// cursor events into file pixels relative to this rect: a captured
51+
/// window is rarely at the display origin, and a secondary display
52+
/// never is.
53+
var contentRectPoints: CGRect
4954
var scaleFactor: CGFloat
5055
var hasSystemAudio: Bool
5156
var hasMic: Bool
@@ -160,12 +165,17 @@ final class ScreenCaptureService: NSObject {
160165
}
161166

162167
let scale = Self.backingScale(for: display.displayID)
163-
let displayPoints = CGRect(x: 0, y: 0, width: CGFloat(display.width), height: CGFloat(display.height))
164168

165-
// 2. Output dimensions in pixels, sourceRect in points
169+
// 2. Output dimensions in pixels, sourceRect in points, and the
170+
// content's global rect (points, top-left origin) for cursor mapping.
171+
// Passing full-display bounds regardless of mode gave window captures
172+
// the wrong point→pixel scale AND ignored the window's position — the
173+
// synthetic cursor landed far from the real pointer for every window
174+
// recording.
166175
var outputW: Int
167176
var outputH: Int
168177
var sourceRect: CGRect? = nil
178+
var contentRectPoints: CGRect
169179
if let preBuilt = request.preBuiltFilter {
170180
// Native picker gave us a filter — its contentRect is in points,
171181
// and pointPixelScale converts to native pixels. Using contentRect
@@ -175,6 +185,7 @@ final class ScreenCaptureService: NSObject {
175185
let pxScale = CGFloat(preBuilt.pointPixelScale)
176186
outputW = Int(rect.width * pxScale)
177187
outputH = Int(rect.height * pxScale)
188+
contentRectPoints = rect
178189
} else if let window = window {
179190
// Window capture (legacy windowID path): build the filter early
180191
// so we can use its contentRect for sizing. This is what makes
@@ -185,6 +196,7 @@ final class ScreenCaptureService: NSObject {
185196
let pxScale = CGFloat(probeFilter.pointPixelScale)
186197
outputW = Int(rect.width * pxScale)
187198
outputH = Int(rect.height * pxScale)
199+
contentRectPoints = rect
188200
} else if let region = request.region {
189201
outputW = Int(region.width)
190202
outputH = Int(region.height)
@@ -194,9 +206,18 @@ final class ScreenCaptureService: NSObject {
194206
width: region.size.width / scale,
195207
height: region.size.height / scale
196208
)
209+
contentRectPoints = CGRect(
210+
x: display.frame.minX + region.origin.x / scale,
211+
y: display.frame.minY + region.origin.y / scale,
212+
width: region.size.width / scale,
213+
height: region.size.height / scale
214+
)
197215
} else {
198216
outputW = Int(CGFloat(display.width) * scale)
199217
outputH = Int(CGFloat(display.height) * scale)
218+
// display.frame carries the display's global origin — (0,0) for
219+
// the primary display, but not for secondaries.
220+
contentRectPoints = display.frame
200221
}
201222

202223
// Cap the longest side to 2560px so the encoder doesn't get drowned
@@ -427,7 +448,7 @@ final class ScreenCaptureService: NSObject {
427448
pixelWidth: outputW,
428449
pixelHeight: outputH,
429450
fps: request.fps,
430-
displayBounds: displayPoints,
451+
contentRectPoints: contentRectPoints,
431452
scaleFactor: scale,
432453
hasSystemAudio: sysInput != nil,
433454
hasMic: mInput != nil

0 commit comments

Comments
 (0)