Skip to content

feat: scrolling capture (long screenshots) with vertical stitching#82

Open
iOSDevSK wants to merge 1 commit into
KartikLabhshetwar:mainfrom
iOSDevSK:feat/scrolling-capture
Open

feat: scrolling capture (long screenshots) with vertical stitching#82
iOSDevSK wants to merge 1 commit into
KartikLabhshetwar:mainfrom
iOSDevSK:feat/scrolling-capture

Conversation

@iOSDevSK

Copy link
Copy Markdown

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

  • Region selection reuses the existing RegionSelectionOverlay
  • Frames are grabbed continuously via ScreenCaptureKit (SCScreenshotManager); ScrollStitcher finds the overlap between consecutive frames by pixel matching and appends only the new rows
  • Floating HUD shows the live stitched height, an auto-scroll toggle, and Done/Cancel; a border overlay marks the captured region; Esc cancels
  • New "Scrolling" button in the menu bar capture grid
  • The final image runs through the standard pipeline — history, beautifier defaults, preview card — via a new CaptureOrchestrator.finishExternalCapture

Scope

  • New: Sources/Capture/ScrollingCapture.swift
  • Small hooks: menu bar button, finishExternalCapture in CaptureOrchestrator, pbxproj entry, CHANGELOG.md under Unreleased
  • No new permissions, no version bump

Testing

  • make release builds clean
  • Manually verified: long web pages and chat windows stitch correctly with manual and auto-scroll; Esc/Cancel discards; result lands in history with beautifier defaults and shows the preview card

Note: on the newest Xcode toolchains main itself doesn't compile — see #80 for minimal Swift 6 concurrency fixes. This PR is independent of it.

🤖 Generated with Claude Code

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>
@vercel

vercel Bot commented Jul 14, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the knox projects Team on Vercel.

A member of the Team first needs to authorize it.

Comment on lines +53 to +69
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple footguns here: early returns can leave captureScreen set, and if importCapture fails you can end up running galleryApplyAndSave on the previous lastCaptureURL.

Suggested change
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)
}

Comment on lines +119 to +133
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
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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
)

Comment on lines +178 to +190
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant