Skip to content

Commit 2de7c94

Browse files
committed
Fix editor trim and click highlight export
1 parent 0bcbdf0 commit 2de7c94

16 files changed

Lines changed: 761 additions & 112 deletions

Sources/Gifrog/Controllers/GifrogController.swift

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,11 @@ final class GifrogController: NSObject, ObservableObject {
197197
switch selectedMode {
198198
case .region:
199199
regionSelector?.show(
200-
initialRegion: settings.lastRegion,
200+
initialRegion: currentRegion ?? settings.lastRegion,
201201
onSelectionChanged: { [weak self] region in
202202
guard let self else { return }
203203
self.currentRegion = region
204+
self.settings.lastRegion = region
204205
if self.phase != .ready {
205206
self.phase = .ready
206207
}
@@ -219,12 +220,16 @@ final class GifrogController: NSObject, ObservableObject {
219220
}
220221
prepareRecording(region: CaptureRegion.screen(screen))
221222
case .window:
222-
windowPicker?.show()
223+
windowPicker?.show(preferredWindowID: currentRegion?.windowID)
223224
}
224225
}
225226

226227
func prepareRecording(region: CaptureRegion) {
227228
currentRegion = region
229+
if selectedMode == .region {
230+
settings.lastRegion = region
231+
saveSettings()
232+
}
228233
phase = .ready
229234
elapsed = 0
230235
toolbarController?.show(near: region)
@@ -240,13 +245,25 @@ final class GifrogController: NSObject, ObservableObject {
240245
prepareRecording(region: currentRegion)
241246
}
242247

248+
func reselectCaptureArea() {
249+
elapsed = 0
250+
countdownTimer?.invalidate()
251+
toolbarController?.hide()
252+
phase = .idle
253+
startCaptureFlow()
254+
}
255+
243256
func startCountdown() {
244-
guard currentRegion != nil else {
257+
guard let currentRegion else {
245258
fail(GifrogError.noRegion)
246259
return
247260
}
248261

249262
countdownTimer?.invalidate()
263+
if selectedMode == .region {
264+
settings.lastRegion = currentRegion
265+
saveSettings()
266+
}
250267
countdown = max(0, settings.countdownSeconds)
251268

252269
if countdown == 0 {

Sources/Gifrog/Controllers/WindowPickerController.swift

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,20 @@ struct CapturableWindow: Identifiable {
77
var owner: String
88
var title: String
99
var bounds: CGRect
10+
var screenFrame: CGRect
1011

1112
var displayName: String {
1213
title.isEmpty ? owner : "\(owner) - \(title)"
1314
}
1415

16+
var captureRegion: CaptureRegion {
17+
CaptureRegion(
18+
globalRect: OverlayGeometry.appKitWindowRect(cgWindowBounds: bounds, screenFrame: screenFrame),
19+
captureRect: bounds,
20+
windowID: id
21+
)
22+
}
23+
1524
static func list() -> [CapturableWindow] {
1625
let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
1726
guard let windows = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else {
@@ -31,13 +40,20 @@ struct CapturableWindow: Identifiable {
3140
return nil
3241
}
3342

43+
guard let screenFrame = Self.screenFrame(for: bounds) else { return nil }
3444
let title = info[kCGWindowName as String] as? String ?? ""
35-
return CapturableWindow(id: number, owner: owner, title: title, bounds: bounds)
45+
return CapturableWindow(id: number, owner: owner, title: title, bounds: bounds, screenFrame: screenFrame)
3646
}
3747
.prefix(40)
3848
.map { $0 }
3949
}
4050

51+
private static func screenFrame(for bounds: CGRect) -> CGRect? {
52+
NSScreen.screens.max { left, right in
53+
left.frame.intersection(bounds).area < right.frame.intersection(bounds).area
54+
}?.frame
55+
}
56+
4157
func thumbnail() -> NSImage? {
4258
guard let cgImage = CGWindowListCreateImage(
4359
.null,
@@ -58,6 +74,13 @@ struct CapturableWindow: Identifiable {
5874
}
5975
}
6076

77+
private extension CGRect {
78+
var area: CGFloat {
79+
guard !isNull && !isInfinite else { return 0 }
80+
return max(0, width) * max(0, height)
81+
}
82+
}
83+
6184
final class WindowPickerController {
6285
private let app: GifrogController
6386
private var panel: NSPanel?
@@ -66,18 +89,31 @@ final class WindowPickerController {
6689
self.app = app
6790
}
6891

69-
func show() {
92+
func show(preferredWindowID: UInt32? = nil) {
93+
let windows = Self.sortedWindows(preferredWindowID: preferredWindowID)
7094
let panel = panel ?? NSPanel(
7195
contentRect: NSRect(x: 0, y: 0, width: 420, height: 480),
7296
styleMask: [.titled, .closable, .utilityWindow],
7397
backing: .buffered,
7498
defer: false
7599
)
76100
panel.title = "Choose Window"
77-
panel.contentView = NSHostingView(rootView: WindowPickerView(app: app, windows: CapturableWindow.list()))
101+
panel.contentView = NSHostingView(
102+
rootView: WindowPickerView(app: app, windows: windows, preferredWindowID: preferredWindowID)
103+
)
78104
panel.center()
79105
panel.makeKeyAndOrderFront(nil)
80106
NSApplication.shared.activate(ignoringOtherApps: true)
81107
self.panel = panel
82108
}
109+
110+
private static func sortedWindows(preferredWindowID: UInt32?) -> [CapturableWindow] {
111+
let windows = CapturableWindow.list()
112+
guard let preferredWindowID else { return windows }
113+
return windows.sorted { lhs, rhs in
114+
if lhs.id == preferredWindowID { return true }
115+
if rhs.id == preferredWindowID { return false }
116+
return false
117+
}
118+
}
83119
}

Sources/Gifrog/Export/ExportManager.swift

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,9 @@ struct ExportManager {
9999
let nextVideoLabel = index == clicks.count - 1 ? "vout" : "v\(index + 1)"
100100
let x = "clip(W*\(ff(click.event.normalizedX)),w/2,W-w/2)-w/2"
101101
let y = "clip(H*\(ff(click.event.normalizedY)),h/2,H-h/2)-h/2"
102-
let fadeDuration = min(ClickHighlightStyle.fadeOutDuration, click.duration)
103-
let fadeStart = max(0.08, click.duration - fadeDuration)
104-
graph += ";[\(inputIndex):v]format=rgba,trim=duration=\(ff(click.duration)),setpts=PTS-STARTPTS+\(ff(click.start))/TB,fade=t=out:st=\(ff(fadeStart)):d=\(ff(fadeDuration)):alpha=1[\(spriteLabel)]"
105-
graph += ";[v\(index)][\(spriteLabel)]overlay=x='\(x)':y='\(y)':eof_action=pass:shortest=0[\(nextVideoLabel)]"
102+
let end = click.start + click.duration
103+
graph += ";[\(inputIndex):v]format=rgba[\(spriteLabel)]"
104+
graph += ";[v\(index)][\(spriteLabel)]overlay=x='\(x)':y='\(y)':enable='between(t,\(ff(click.start)),\(ff(end)))':eof_action=pass:shortest=0[\(nextVideoLabel)]"
106105
}
107106
return graph
108107
}
@@ -156,12 +155,12 @@ struct ExportManager {
156155
width: ClickHighlightStyle.circleRadius * 2,
157156
height: ClickHighlightStyle.circleRadius * 2
158157
)
159-
context.setFillColor(NSColor.white.withAlphaComponent(ClickHighlightStyle.fillOpacity).cgColor)
158+
context.setFillColor(ClickHighlightStyle.fillColor.withAlphaComponent(ClickHighlightStyle.fillOpacity).cgColor)
160159
context.fillEllipse(in: rect)
161-
context.setStrokeColor(NSColor.black.withAlphaComponent(ClickHighlightStyle.contrastStrokeOpacity).cgColor)
160+
context.setStrokeColor(ClickHighlightStyle.contrastStrokeColor.withAlphaComponent(ClickHighlightStyle.contrastStrokeOpacity).cgColor)
162161
context.setLineWidth(ClickHighlightStyle.borderWidth + ClickHighlightStyle.contrastBorderWidth * 2)
163162
context.strokeEllipse(in: rect.insetBy(dx: ClickHighlightStyle.contrastBorderWidth / 2, dy: ClickHighlightStyle.contrastBorderWidth / 2))
164-
context.setStrokeColor(NSColor.white.withAlphaComponent(ClickHighlightStyle.strokeOpacity).cgColor)
163+
context.setStrokeColor(ClickHighlightStyle.strokeColor.withAlphaComponent(ClickHighlightStyle.strokeOpacity).cgColor)
165164
context.setLineWidth(ClickHighlightStyle.borderWidth)
166165
context.strokeEllipse(in: rect.insetBy(dx: ClickHighlightStyle.borderWidth / 2, dy: ClickHighlightStyle.borderWidth / 2))
167166

Sources/Gifrog/Models.swift

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ struct AppSettings: Codable, Equatable {
101101
savePath = try container.decodeIfPresent(String.self, forKey: .savePath) ?? AppPaths.defaultSavePath.path
102102
autoCheckUpdates = try container.decodeIfPresent(Bool.self, forKey: .autoCheckUpdates) ?? false
103103
anonymousUsageStats = try container.decodeIfPresent(Bool.self, forKey: .anonymousUsageStats) ?? false
104+
lastRegion = try container.decodeIfPresent(CaptureRegion.self, forKey: .lastRegion)
104105
}
105106
}
106107

@@ -129,15 +130,18 @@ struct ClickEvent: Codable, Identifiable, Equatable {
129130
}
130131

131132
enum ClickHighlightStyle {
132-
static let spriteSize: CGFloat = 72
133-
static let circleRadius: CGFloat = 23
134-
static let borderWidth: CGFloat = 3
133+
static let spriteSize: CGFloat = 26
134+
static let circleRadius: CGFloat = 7.5
135+
static let borderWidth: CGFloat = 2
135136
static let contrastBorderWidth: CGFloat = 1
136-
static let visibleDuration: Double = 0.48
137-
static let fadeOutDuration: Double = 0.16
138-
static let fillOpacity: CGFloat = 0.32
139-
static let strokeOpacity: CGFloat = 0.78
140-
static let contrastStrokeOpacity: CGFloat = 0.24
137+
static let visibleDuration: Double = 0.42
138+
static let fadeOutDuration: Double = 0.12
139+
static let fillColor = NSColor.white
140+
static let strokeColor = NSColor.white
141+
static let contrastStrokeColor = NSColor.black
142+
static let fillOpacity: CGFloat = 0.30
143+
static let strokeOpacity: CGFloat = 0.86
144+
static let contrastStrokeOpacity: CGFloat = 0.30
141145
}
142146

143147
struct Project: Codable, Identifiable, Equatable {

Sources/Gifrog/OverlayGeometry.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ enum OverlayGeometry {
5454
)
5555
}
5656

57+
static func appKitWindowRect(cgWindowBounds: CGRect, screenFrame: CGRect) -> CGRect {
58+
CGRect(
59+
x: cgWindowBounds.minX,
60+
y: screenFrame.maxY - cgWindowBounds.maxY,
61+
width: cgWindowBounds.width,
62+
height: cgWindowBounds.height
63+
)
64+
}
65+
5766
static func normalizedPoint(globalPoint: CGPoint, in region: CGRect) -> CGPoint? {
5867
guard region.contains(globalPoint) else { return nil }
5968
return CGPoint(
@@ -62,6 +71,22 @@ enum OverlayGeometry {
6271
)
6372
}
6473

74+
static func aspectFitRect(contentSize: CGSize, containerSize: CGSize) -> CGRect {
75+
guard contentSize.width > 0, contentSize.height > 0, containerSize.width > 0, containerSize.height > 0 else {
76+
return .zero
77+
}
78+
79+
let scale = min(containerSize.width / contentSize.width, containerSize.height / contentSize.height)
80+
let width = contentSize.width * scale
81+
let height = contentSize.height * scale
82+
return CGRect(
83+
x: (containerSize.width - width) / 2,
84+
y: (containerSize.height - height) / 2,
85+
width: width,
86+
height: height
87+
)
88+
}
89+
6590
static func toolbarBottomInset(panelHeight: CGFloat) -> CGFloat {
6691
max(0, (panelHeight - toolbarContentHeight) / 2)
6792
}

Sources/Gifrog/Recording/ClickEventRecorder.swift

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,9 @@ final class ClickEventRecorder {
8585
}
8686

8787
private static func screenLocation(for event: NSEvent) -> CGPoint {
88-
if let window = event.window {
89-
return window.convertPoint(toScreen: event.locationInWindow)
90-
}
91-
92-
return event.locationInWindow
88+
// Global and local mouse monitors report `locationInWindow` in different
89+
// coordinate spaces. `mouseLocation` is consistently in AppKit screen
90+
// coordinates, which is the same space as CaptureRegion.globalRect.
91+
NSEvent.mouseLocation
9392
}
9493
}

Sources/Gifrog/TimelineTrim.swift

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import Foundation
2+
3+
enum TimelineTrim {
4+
enum Edge {
5+
case start
6+
case end
7+
}
8+
9+
enum DragTarget: Equatable {
10+
case start
11+
case end
12+
case playhead
13+
}
14+
15+
static let minimumDuration = 0.1
16+
17+
static func clampedRange(start: Double, end: Double, duration: Double) -> (start: Double, end: Double) {
18+
let duration = max(0, duration)
19+
guard duration > 0 else { return (0, 0) }
20+
21+
let minDuration = min(minimumDuration, duration)
22+
var clampedStart = clamp(start, lower: 0, upper: duration)
23+
var clampedEnd = clamp(end > 0 ? end : duration, lower: 0, upper: duration)
24+
25+
if clampedEnd <= clampedStart {
26+
clampedEnd = duration
27+
}
28+
29+
if clampedEnd - clampedStart < minDuration {
30+
if clampedStart + minDuration <= duration {
31+
clampedEnd = clampedStart + minDuration
32+
} else {
33+
clampedStart = max(0, duration - minDuration)
34+
clampedEnd = duration
35+
}
36+
}
37+
38+
return (clampedStart, clampedEnd)
39+
}
40+
41+
static func updatedRange(
42+
moving edge: Edge,
43+
to proposedTime: Double,
44+
start: Double,
45+
end: Double,
46+
duration: Double
47+
) -> (start: Double, end: Double) {
48+
let duration = max(0, duration)
49+
guard duration > 0 else { return (0, 0) }
50+
51+
let minDuration = min(minimumDuration, duration)
52+
switch edge {
53+
case .start:
54+
let clampedEnd = clamp(end, lower: minDuration, upper: duration)
55+
let clampedStart = clamp(proposedTime, lower: 0, upper: clampedEnd - minDuration)
56+
return (clampedStart, clampedEnd)
57+
case .end:
58+
let clampedStart = clamp(start, lower: 0, upper: max(0, duration - minDuration))
59+
let clampedEnd = clamp(proposedTime, lower: clampedStart + minDuration, upper: duration)
60+
return (clampedStart, clampedEnd)
61+
}
62+
}
63+
64+
static func clampedTime(_ seconds: Double, start: Double, end: Double, duration: Double) -> Double {
65+
let range = clampedRange(start: start, end: end, duration: duration)
66+
return clamp(seconds, lower: range.start, upper: range.end)
67+
}
68+
69+
static func time(forFraction fraction: Double, duration: Double) -> Double {
70+
clamp(fraction, lower: 0, upper: 1) * max(0, duration)
71+
}
72+
73+
static func fraction(forTime seconds: Double, duration: Double) -> Double {
74+
guard duration > 0 else { return 0 }
75+
return clamp(seconds / duration, lower: 0, upper: 1)
76+
}
77+
78+
static func fraction(forX x: Double, width: Double) -> Double {
79+
guard width > 0 else { return 0 }
80+
return clamp(x / width, lower: 0, upper: 1)
81+
}
82+
83+
static func playheadFraction(
84+
forTime seconds: Double,
85+
start: Double,
86+
end: Double,
87+
duration: Double
88+
) -> Double {
89+
fraction(forTime: clampedTime(seconds, start: start, end: end, duration: duration), duration: duration)
90+
}
91+
92+
static func thumbnailTileWidth(totalWidth: Double, count: Int, spacing: Double) -> Double {
93+
guard count > 0 else { return 0 }
94+
let availableWidth = max(0, totalWidth - spacing * Double(max(0, count - 1)))
95+
return max(1, availableWidth / Double(count))
96+
}
97+
98+
static func dragTarget(
99+
x: Double,
100+
startX: Double,
101+
endX: Double,
102+
playheadX: Double,
103+
handleHitWidth: Double,
104+
playheadHitWidth: Double
105+
) -> DragTarget? {
106+
if abs(x - startX) <= handleHitWidth / 2 {
107+
return .start
108+
}
109+
if abs(x - endX) <= handleHitWidth / 2 {
110+
return .end
111+
}
112+
if abs(x - playheadX) <= playheadHitWidth / 2 {
113+
return .playhead
114+
}
115+
return nil
116+
}
117+
118+
private static func clamp(_ value: Double, lower: Double, upper: Double) -> Double {
119+
min(max(value, lower), upper)
120+
}
121+
}

0 commit comments

Comments
 (0)