feat: scrolling capture (long screenshots) with vertical stitching#82
feat: scrolling capture (long screenshots) with vertical stitching#82iOSDevSK wants to merge 1 commit into
Conversation
CleanShot X-style scrolling capture: select a region, scroll manually or use auto-scroll, and frames are captured continuously via ScreenCaptureKit and stitched vertically by pixel matching into one tall screenshot. - Floating HUD with live stitched height, auto-scroll toggle, and Done/Cancel controls; region border overlay; Esc cancels - New "Scrolling" button in the menu bar capture grid - Result runs through the standard pipeline (history, beautifier, preview) via CaptureOrchestrator.finishExternalCapture Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Someone is attempting to deploy a commit to the knox projects Team on Vercel. A member of the Team first needs to authorize it. |
| func finishExternalCapture(cgImage: CGImage, on screen: NSScreen?) async { | ||
| captureScreen = screen | ||
| let tempPath = NSTemporaryDirectory() + "bettershot_scroll_\(Int(Date().timeIntervalSince1970 * 1000)).png" | ||
| let tempURL = URL(fileURLWithPath: tempPath) | ||
| guard let dest = CGImageDestinationCreateWithURL(tempURL as CFURL, "public.png" as CFString, 1, nil) else { return } | ||
| CGImageDestinationAddImage(dest, cgImage, nil) | ||
| guard CGImageDestinationFinalize(dest) else { return } | ||
| ScreenCapture.shared.playShutterSound() | ||
| let record = HistoryStore.shared.importCapture(from: tempURL) | ||
| if let record { | ||
| lastCaptureURL = HistoryStore.shared.urlForRecord(record) | ||
| } | ||
| if let capturedURL = lastCaptureURL { | ||
| await galleryApplyAndSave(capturedURL, recordID: record?.id) | ||
| } | ||
| captureScreen = nil | ||
| } |
There was a problem hiding this comment.
A couple footguns here: early returns can leave captureScreen set, and if importCapture fails you can end up running galleryApplyAndSave on the previous lastCaptureURL.
| func finishExternalCapture(cgImage: CGImage, on screen: NSScreen?) async { | |
| captureScreen = screen | |
| let tempPath = NSTemporaryDirectory() + "bettershot_scroll_\(Int(Date().timeIntervalSince1970 * 1000)).png" | |
| let tempURL = URL(fileURLWithPath: tempPath) | |
| guard let dest = CGImageDestinationCreateWithURL(tempURL as CFURL, "public.png" as CFString, 1, nil) else { return } | |
| CGImageDestinationAddImage(dest, cgImage, nil) | |
| guard CGImageDestinationFinalize(dest) else { return } | |
| ScreenCapture.shared.playShutterSound() | |
| let record = HistoryStore.shared.importCapture(from: tempURL) | |
| if let record { | |
| lastCaptureURL = HistoryStore.shared.urlForRecord(record) | |
| } | |
| if let capturedURL = lastCaptureURL { | |
| await galleryApplyAndSave(capturedURL, recordID: record?.id) | |
| } | |
| captureScreen = nil | |
| } | |
| func finishExternalCapture(cgImage: CGImage, on screen: NSScreen?) async { | |
| captureScreen = screen | |
| defer { captureScreen = nil } | |
| let tempPath = NSTemporaryDirectory() + "bettershot_scroll_\(Int(Date().timeIntervalSince1970 * 1000)).png" | |
| let tempURL = URL(fileURLWithPath: tempPath) | |
| guard let dest = CGImageDestinationCreateWithURL(tempURL as CFURL, "public.png" as CFString, 1, nil) else { return } | |
| CGImageDestinationAddImage(dest, cgImage, nil) | |
| guard CGImageDestinationFinalize(dest) else { return } | |
| ScreenCapture.shared.playShutterSound() | |
| guard let record = HistoryStore.shared.importCapture(from: tempURL) else { return } | |
| try? FileManager.default.removeItem(at: tempURL) | |
| let capturedURL = HistoryStore.shared.urlForRecord(record) | |
| lastCaptureURL = capturedURL | |
| await galleryApplyAndSave(capturedURL, recordID: record.id) | |
| } |
| let contentRect = try await filter.contentRect | ||
| let pointPixelScale = try await filter.pointPixelScale | ||
| let screenFrame = NSScreen.screens.first?.frame ?? NSRect(x: 0, y: 0, width: CGFloat(display.width), height: CGFloat(display.height)) | ||
|
|
||
| let selRect = selection.pointsRect | ||
| let scaleX = contentRect.width / screenFrame.width | ||
| let scaleY = contentRect.height / screenFrame.height | ||
|
|
||
| sourceRect = CGRect( | ||
| x: contentRect.minX + (selRect.minX - screenFrame.minX) * scaleX, | ||
| y: contentRect.minY + selRect.minY * scaleY, | ||
| width: selRect.width * scaleX, | ||
| height: selRect.height * scaleY | ||
| ) | ||
|
|
There was a problem hiding this comment.
This is mixing targetScreen/selection coordinates with content.displays.first + NSScreen.screens.first (and minY is handled differently than minX). On multi-monitor setups this looks like it can capture the wrong display/rect.
| let contentRect = try await filter.contentRect | |
| let pointPixelScale = try await filter.pointPixelScale | |
| let screenFrame = NSScreen.screens.first?.frame ?? NSRect(x: 0, y: 0, width: CGFloat(display.width), height: CGFloat(display.height)) | |
| let selRect = selection.pointsRect | |
| let scaleX = contentRect.width / screenFrame.width | |
| let scaleY = contentRect.height / screenFrame.height | |
| sourceRect = CGRect( | |
| x: contentRect.minX + (selRect.minX - screenFrame.minX) * scaleX, | |
| y: contentRect.minY + selRect.minY * scaleY, | |
| width: selRect.width * scaleX, | |
| height: selRect.height * scaleY | |
| ) | |
| let contentRect = try await filter.contentRect | |
| let pointPixelScale = try await filter.pointPixelScale | |
| let screenFrame = (targetScreen ?? NSScreen.main)?.frame ?? NSRect(x: 0, y: 0, width: CGFloat(display.width), height: CGFloat(display.height)) | |
| let selRect = selection.pointsRect | |
| let scaleX = contentRect.width / screenFrame.width | |
| let scaleY = contentRect.height / screenFrame.height | |
| sourceRect = CGRect( | |
| x: contentRect.minX + (selRect.minX - screenFrame.minX) * scaleX, | |
| y: contentRect.minY + (selRect.minY - screenFrame.minY) * scaleY, | |
| width: selRect.width * scaleX, | |
| height: selRect.height * scaleY | |
| ) |
| let center = CGPoint(x: regionPointsRect.midX, y: regionPointsRect.midY) | ||
|
|
||
| autoScrollTask = Task { [weak self] in | ||
| var stagnantRounds = 0 | ||
| // Warp the pointer into the region so scroll events land in it. | ||
| CGWarpMouseCursorPosition(center) | ||
|
|
||
| while !Task.isCancelled { | ||
| guard let self, self.state == .capturing else { return } | ||
|
|
||
| if let event = CGEvent(scrollWheelEvent2Source: nil, units: .line, wheelCount: 1, wheel1: -5, wheel2: 0, wheel3: 0) { | ||
| event.location = center | ||
| event.post(tap: .cghidEventTap) |
There was a problem hiding this comment.
regionPointsRect is treated as top-left origin elsewhere (appKitRect), but the auto-scroll warp/event uses it directly. Using the same conversion here makes the coordinate system consistent.
| let center = CGPoint(x: regionPointsRect.midX, y: regionPointsRect.midY) | |
| autoScrollTask = Task { [weak self] in | |
| var stagnantRounds = 0 | |
| // Warp the pointer into the region so scroll events land in it. | |
| CGWarpMouseCursorPosition(center) | |
| while !Task.isCancelled { | |
| guard let self, self.state == .capturing else { return } | |
| if let event = CGEvent(scrollWheelEvent2Source: nil, units: .line, wheelCount: 1, wheel1: -5, wheel2: 0, wheel3: 0) { | |
| event.location = center | |
| event.post(tap: .cghidEventTap) | |
| let region = appKitRect(regionPointsRect) | |
| let center = CGPoint(x: region.midX, y: region.midY) | |
| autoScrollTask = Task { [weak self] in | |
| var stagnantRounds = 0 | |
| // Warp the pointer into the region so scroll events land in it. | |
| CGWarpMouseCursorPosition(center) | |
| while !Task.isCancelled { | |
| guard let self, self.state == .capturing else { return } | |
| if let event = CGEvent(scrollWheelEvent2Source: nil, units: .line, wheelCount: 1, wheel1: -5, wheel2: 0, wheel3: 0) { | |
| event.location = center | |
| event.post(tap: .cghidEventTap) | |
| } |
What
CleanShot X–style scrolling capture: select a region, scroll through the content (manually or with the built-in auto-scroll), and the frames are stitched vertically by pixel matching into one tall screenshot.
How it works
RegionSelectionOverlaySCScreenshotManager);ScrollStitcherfinds the overlap between consecutive frames by pixel matching and appends only the new rowsCaptureOrchestrator.finishExternalCaptureScope
Sources/Capture/ScrollingCapture.swiftfinishExternalCaptureinCaptureOrchestrator, pbxproj entry,CHANGELOG.mdunder UnreleasedTesting
make releasebuilds cleanNote: on the newest Xcode toolchains
mainitself doesn't compile — see #80 for minimal Swift 6 concurrency fixes. This PR is independent of it.🤖 Generated with Claude Code