Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 127 additions & 12 deletions macshot/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate {
HotkeyManager.applyMenuShortcut(for: .recordArea, to: recordAreaItem)
menu.addItem(recordAreaItem)

let recordGIFItem = NSMenuItem(title: "\(L("Record")) \(L("GIF"))", action: #selector(recordGIF), keyEquivalent: "")
recordGIFItem.target = self
recordGIFItem.image = NSImage(systemSymbolName: "photo.stack", accessibilityDescription: nil)
HotkeyManager.applyMenuShortcut(for: .recordGIF, to: recordGIFItem)
menu.addItem(recordGIFItem)

let recordScreenItem = NSMenuItem(title: L("Record Screen"), action: #selector(recordFullScreen), keyEquivalent: "")
recordScreenItem.target = self
recordScreenItem.image = NSImage(systemSymbolName: "menubar.dock.rectangle", accessibilityDescription: nil)
Expand Down Expand Up @@ -996,11 +1002,29 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate {
},
clearHistory: { [weak self] in
DispatchQueue.main.async { self?.clearHistorySilently() }
},
recordGIF: { [weak self] in
stamp()
self?.perform(#selector(AppDelegate.recordGIFFromHotkey))
}
)
}

private var pendingRecordMode: Bool = false
/// Set by the Record GIF entry points: the next recording is converted to
/// a GIF when it stops. Every record entry point assigns it, so a
/// cancelled GIF selection can't leak into a later recording.
private var nextRecordingIsGIF: Bool = false
/// Output details of the recording being set up, captured from the overlay
/// before it is dismissed (used for GIF conversion and archive naming).
private var pendingRecordingOutput = RecordingOutput()

private struct RecordingOutput {
var isGIF = false
var appName: String?
var startDate = Date()
var backingScale: CGFloat = 1
}
private var pendingFullScreen: Bool = false
private var pendingFullScreenRecord: Bool = false
private var pendingFullScreenRecordAutoStart: Bool = false
Expand Down Expand Up @@ -1193,15 +1217,27 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate {
}

@objc private func recordAreaFromHotkey() {
// Pressing the record hotkey again stops the take.
if recordingEngine != nil { stopRecording(); return }
beginRecordArea(fromMenu: false)
}

private func beginRecordArea(fromMenu: Bool) {
private func beginRecordArea(fromMenu: Bool, asGIF: Bool = false) {
guard canStartCapture else { return }
nextRecordingIsGIF = asGIF
pendingRecordMode = true
startCapture(fromMenu: fromMenu)
}

@objc private func recordGIF() {
beginRecordArea(fromMenu: true, asGIF: true)
}

@objc private func recordGIFFromHotkey() {
if recordingEngine != nil { stopRecording(); return }
beginRecordArea(fromMenu: false, asGIF: true)
}

@objc private func recordFullScreen() {
beginRecordFullScreen(fromMenu: true)
}
Expand All @@ -1212,6 +1248,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate {

private func beginRecordFullScreen(fromMenu: Bool) {
guard canStartCapture else { return }
nextRecordingIsGIF = false
pendingFullScreenRecord = true
if UserDefaults.standard.integer(forKey: "captureDelaySeconds") > 0 {
pendingFullScreenRecordAutoStart = true
Expand Down Expand Up @@ -2689,6 +2726,9 @@ extension AppDelegate: OverlayWindowControllerDelegate {
let onStopOverride = controller.sessionRecordingOnStop
let delayOverride = controller.sessionRecordingDelay
let hideHUD = controller.sessionHideRecordingHUD ?? UserDefaults.standard.bool(forKey: "hideRecordingHUD")
pendingRecordingOutput = RecordingOutput(isGIF: nextRecordingIsGIF, appName: controller.resolvedAppName(),
startDate: Date(), backingScale: screen.backingScaleFactor)
nextRecordingIsGIF = false

// Detach webcam preview before dismissing overlays so we can reuse the live session
let existingWebcam = controller.detachWebcamPreview()
Expand Down Expand Up @@ -2806,6 +2846,8 @@ extension AppDelegate: OverlayWindowControllerDelegate {
existingWebcam: WebcamOverlay? = nil,
hideHUD: Bool = false) {
let engine = RecordingEngine()
let output = pendingRecordingOutput
pendingRecordingOutput = RecordingOutput()
engine.onProgress = { [weak self] seconds in
self?.updateRecordingHUD(seconds: seconds)
}
Expand Down Expand Up @@ -2833,16 +2875,15 @@ extension AppDelegate: OverlayWindowControllerDelegate {
let deliverRecording: (URL) -> Void = { [weak self] finalURL in
guard let self = self else { return }
let onStop = onStopOverride ?? UserDefaults.standard.string(forKey: "recordingOnStop") ?? "editor"
switch onStop {
case "finder":
// Publish a user-visible copy while keeping the
// original take available in the recording library.
self.revealRecordingInFinder(tmpURL: finalURL)
case "clipboard":
self.copyRecordingToClipboard(url: finalURL)
default:
VideoEditorWindowController.open(url: finalURL)
// The editor exports GIFs itself; every other destination
// receives the converted GIF.
if output.isGIF, onStop != "editor", finalURL.pathExtension.lowercased() != "gif" {
self.convertRecordingToGIF(finalURL, backingScale: output.backingScale) { [weak self] gifURL in
self?.deliverFinishedRecording(gifURL, onStop: onStop, output: output)
}
return
}
self.deliverFinishedRecording(finalURL, onStop: onStop, output: output)
}

// Offer audio merge when both mic + system audio were recorded
Expand Down Expand Up @@ -3024,6 +3065,74 @@ extension AppDelegate: OverlayWindowControllerDelegate {
}
}

private func deliverFinishedRecording(_ finalURL: URL, onStop: String, output: RecordingOutput) {
switch onStop {
case "finder":
// Publish a user-visible copy while keeping the
// original take available in the recording library.
revealRecordingInFinder(tmpURL: finalURL)
case "clipboard":
copyRecordingToClipboard(url: finalURL)
case "save":
archiveRecording(tmpURL: finalURL, appName: output.appName, date: output.startDate)
default:
VideoEditorWindowController.open(url: finalURL)
}
}

/// Record GIF: encode the finished take as a looping GIF at point size.
/// On failure the MP4 is delivered instead, so a take is never lost.
private func convertRecordingToGIF(_ source: URL, backingScale: CGFloat, completion: @escaping (URL) -> Void) {
let gifName = source.deletingPathExtension().lastPathComponent + ".gif"
let gifURL = TmpScratchDirectory.makeURL(filename: gifName)
let savedFPS = UserDefaults.standard.integer(forKey: VideoExportSettings.gifFPSKey)
let fps = (5...30).contains(savedFPS) ? savedFPS : 15
let job = MediaExportCoordinator.shared.start(title: gifName, status: L("Exporting..."),
operation: { cancellation, progress in
try await RecordingGIFConverter.convert(source: source, to: gifURL, fps: fps,
scale: 1 / max(1, backingScale),
cancellation: cancellation, progress: progress)
}, completion: { [weak self] result in
switch result {
case .success:
completion(gifURL)
case .failure(let error):
guard !(error is CancellationError) else { return }
self?.showFailureToast(L("Export failed") + ": " + error.localizedDescription)
completion(source)
}
})
MediaExportProgressController.show(for: job)
}

/// `recordingOnStop = "save"`: publish the take into the recording folder
/// (screenshot folder as fallback) using the recording filename template,
/// whose `/` separators may create dated subfolders, then copy the saved
/// file to the clipboard, like a screenshot's "Save + copy".
private func archiveRecording(tmpURL: URL, appName: String?, date: Date) {
guard let directory = SaveDirectoryAccess.resolveRecordingDirectoryIfAccessible()
?? SaveDirectoryAccess.resolveIfAccessible() else {
promptToSaveRecording(tmpURL: tmpURL)
return
}
let defaults = UserDefaults.standard
let components = FilenameFormatter.formatRelativePath(
template: defaults.string(forKey: FilenameFormatter.recordingUserDefaultsKey)
?? FilenameFormatter.defaultRecordingTemplate,
noAppTemplate: defaults.string(forKey: FilenameFormatter.recordingNoAppUserDefaultsKey),
appName: appName, date: date, fallback: FilenameFormatter.defaultRecordingTemplate)
let ext = tmpURL.pathExtension.isEmpty ? "mp4" : tmpURL.pathExtension
let destination = directory.appendingPathComponent(components.joined(separator: "/"))
.appendingPathExtension(ext)
let access = SaveDirectoryLease(alreadyAccessing: directory)
saveRecordingCopy(source: tmpURL, destination: destination, avoidCollisions: true, access: access,
revealInFinder: false, subfoldersBelow: directory,
onSuccess: { [weak self] savedURL in self?.copyRecordingToClipboard(url: savedURL) }) { [weak self] error in
guard !(error is CancellationError) else { return }
self?.showFailureToast(L("Save failed") + ": " + error.localizedDescription)
}
}

/// Copy a recording to a user-visible directory
/// and reveal it in Finder. Used by the `recordingOnStop = "finder"`
/// flow so the user doesn't end up staring at a deep sandbox path.
Expand Down Expand Up @@ -3071,12 +3180,16 @@ extension AppDelegate: OverlayWindowControllerDelegate {
}

private func saveRecordingCopy(source: URL, destination: URL, avoidCollisions: Bool,
access: SaveDirectoryLease? = nil, onFailure: @escaping (Error) -> Void) {
access: SaveDirectoryLease? = nil, revealInFinder: Bool = true,
subfoldersBelow root: URL? = nil,
onSuccess: ((URL) -> Void)? = nil,
onFailure: @escaping (Error) -> Void) {
var publishedURL = destination
let job = MediaExportCoordinator.shared.start(title: destination.lastPathComponent, status: L("Saving..."),
operation: { cancellation, progress in
publishedURL = try await MediaExportIO.perform {
try cancellation.check()
try ImageSaveService.createSubfolders(for: destination, below: root)
var selectedURL = destination
if avoidCollisions {
let base = destination.deletingPathExtension().lastPathComponent
Expand All @@ -3099,7 +3212,9 @@ extension AppDelegate: OverlayWindowControllerDelegate {
}, completion: { result in
withExtendedLifetime(access) {}
switch result {
case .success: NSWorkspace.shared.activateFileViewerSelecting([publishedURL])
case .success:
if revealInFinder { NSWorkspace.shared.activateFileViewerSelecting([publishedURL]) }
onSuccess?(publishedURL)
case .failure(let error): onFailure(error)
}
})
Expand Down
31 changes: 31 additions & 0 deletions macshot/Capture/RecordingGIFConverter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import AVFoundation

/// Turns a finished recording into a looping GIF for the Record GIF hotkey,
/// through the same `GIFExporter` the video editor's GIF export uses.
enum RecordingGIFConverter {
/// `scale` shrinks the take before encoding: pass `1 / backingScaleFactor`
/// so a Retina recording becomes a GIF at its on-screen point size.
static func convert(source: URL, to output: URL, fps: Int, scale: CGFloat,
cancellation: MediaExportCancellation,
progress: @escaping @Sendable (Double) -> Void) async throws {
let asset = AVURLAsset(url: source)
guard let track = try await asset.loadTracks(withMediaType: .video).first else {
throw GIFExporter.ExportError.invalidSetup
}
let duration = try await asset.load(.duration)
let naturalSize = try await track.load(.naturalSize)
let transform = try await track.load(.preferredTransform)
let upright = CGRect(origin: .zero, size: naturalSize).applying(transform).standardized.size
let factor = min(1, max(0.1, scale))
let renderSize = CGSize(width: max(2, (upright.width * factor).rounded()),
height: max(2, (upright.height * factor).rounded()))
let cadence = CMTime(value: 1, timescale: CMTimeScale(min(30, max(5, fps))))
let composition = try VideoCompositionRendering.scaleComposition(
track: track, renderSize: renderSize, duration: duration, frameDuration: cadence)
let request = GIFExporter.Request(
asset: asset, videoTrack: track, composition: composition,
timeRange: CMTimeRange(start: .zero, duration: duration),
outputURL: output, sourceLease: nil)
try await GIFExporter.export(request, cancellation: cancellation, progress: progress)
}
}
1 change: 1 addition & 0 deletions macshot/Services/FilenameFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ enum FilenameFormatter {
/// but no app is known (e.g. a whole-display capture). Empty or missing
/// means "use the main template anyway".
static let noAppUserDefaultsKey = "filenameTemplateNoApp"
static let recordingNoAppUserDefaultsKey = "recordingFilenameTemplateNoApp"

/// Renders a filename *without* extension from a user-editable template.
///
Expand Down
10 changes: 8 additions & 2 deletions macshot/Services/HotkeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class HotkeyManager {
case captureLastArea = 10
case pinFromClipboard = 11
case clearHistory = 12
case recordGIF = 13

var keyCodeKey: String {
switch self {
Expand All @@ -37,6 +38,7 @@ class HotkeyManager {
case .captureLastArea: return "hotkeyCaptureLastAreaKeyCode"
case .pinFromClipboard: return "hotkeyPinClipboardKeyCode"
case .clearHistory: return "hotkeyClearHistoryKeyCode"
case .recordGIF: return "hotkeyRecordGIFKeyCode"
}
}

Expand All @@ -54,6 +56,7 @@ class HotkeyManager {
case .captureLastArea: return "hotkeyCaptureLastAreaModifiers"
case .pinFromClipboard: return "hotkeyPinClipboardModifiers"
case .clearHistory: return "hotkeyClearHistoryModifiers"
case .recordGIF: return "hotkeyRecordGIFModifiers"
}
}

Expand All @@ -75,6 +78,7 @@ class HotkeyManager {
case .captureLastArea: return L("Capture Last Area")
case .pinFromClipboard: return L("Pin from Clipboard")
case .clearHistory: return L("Clear History")
case .recordGIF: return "\(L("Record")) \(L("GIF"))"
}
}

Expand All @@ -92,12 +96,13 @@ class HotkeyManager {
case .captureLastArea: return 0 // no default hotkey
case .pinFromClipboard: return 0 // no default hotkey
case .clearHistory: return 0 // no default hotkey
case .recordGIF: return 0 // no default hotkey
}
}

var defaultModifiers: UInt32 {
switch self {
case .recordScreen, .scrollCapture, .openFromClipboard, .captureLastArea, .pinFromClipboard, .clearHistory: return 0
case .recordScreen, .scrollCapture, .openFromClipboard, .captureLastArea, .pinFromClipboard, .clearHistory, .recordGIF: return 0
default: return UInt32(cmdKey | shiftKey)
}
}
Expand Down Expand Up @@ -136,7 +141,7 @@ class HotkeyManager {
}

/// Register all hotkeys with their callbacks.
func registerAll(captureArea: @escaping () -> Void, captureFullScreen: @escaping () -> Void, recordArea: @escaping () -> Void, recordScreen: @escaping () -> Void, historyOverlay: @escaping () -> Void, captureOCR: @escaping () -> Void, quickCapture: @escaping () -> Void, scrollCapture: @escaping () -> Void, openFromClipboard: @escaping () -> Void, captureLastArea: @escaping () -> Void, pinFromClipboard: @escaping () -> Void, clearHistory: @escaping () -> Void) {
func registerAll(captureArea: @escaping () -> Void, captureFullScreen: @escaping () -> Void, recordArea: @escaping () -> Void, recordScreen: @escaping () -> Void, historyOverlay: @escaping () -> Void, captureOCR: @escaping () -> Void, quickCapture: @escaping () -> Void, scrollCapture: @escaping () -> Void, openFromClipboard: @escaping () -> Void, captureLastArea: @escaping () -> Void, pinFromClipboard: @escaping () -> Void, clearHistory: @escaping () -> Void, recordGIF: @escaping () -> Void) {
unregisterAll()
register(slot: .captureArea, callback: captureArea)
register(slot: .captureFullScreen, callback: captureFullScreen)
Expand All @@ -150,6 +155,7 @@ class HotkeyManager {
register(slot: .captureLastArea, callback: captureLastArea)
register(slot: .pinFromClipboard, callback: pinFromClipboard)
register(slot: .clearHistory, callback: clearHistory)
register(slot: .recordGIF, callback: recordGIF)
}

private func installEventHandler() {
Expand Down
5 changes: 3 additions & 2 deletions macshot/UI/Overlay/OverlayView+Popovers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ extension OverlayView {
super.init()
}
@objc func changed(_ sender: NSPopUpButton) {
let values = ["editor", "finder", "clipboard"]
let values = ["editor", "finder", "clipboard", "save"]
overlayView?.sessionRecordingOnStop = values[sender.indexOfSelectedItem]
}
}
Expand All @@ -313,12 +313,13 @@ extension OverlayView {

// When done popup
let whenDonePopup = NSPopUpButton()
whenDonePopup.addItems(withTitles: [L("Open editor"), L("Show in Finder"), L("Copy to clipboard")])
whenDonePopup.addItems(withTitles: [L("Open editor"), L("Show in Finder"), L("Copy to clipboard"), L("Save + copy to clipboard")])
whenDonePopup.controlSize = .small
whenDonePopup.font = NSFont.systemFont(ofSize: 11)
switch effectiveOnStop {
case "finder": whenDonePopup.selectItem(at: 1)
case "clipboard": whenDonePopup.selectItem(at: 2)
case "save": whenDonePopup.selectItem(at: 3)
default: whenDonePopup.selectItem(at: 0)
}

Expand Down
Loading
Loading