From 34aa25ba3a2a423d08e7050bcbfc4f18e29f774c Mon Sep 17 00:00:00 2001 From: Matthew Toupin Date: Fri, 25 Sep 2026 11:45:40 -0600 Subject: [PATCH] Add Record GIF hotkey and "Save + copy" when a recording stops - Record GIF (hotkey slot + menu item, no default binding): same area / window / display picker as Record Area; the take is converted with the editor's GIFExporter at point size (gifExportFPS, default 15). With "Open editor" the MP4 opens as before. - Record Area and Record GIF hotkeys stop an active recording. - New "When done: Save + copy to clipboard": saves the take to the recording folder via the recording template (subfolders and {app} supported, optional recordingFilenameTemplateNoApp), then copies it. Co-Authored-By: Claude Opus 5.5 --- macshot/AppDelegate.swift | 139 ++++++++++++++++-- macshot/Capture/RecordingGIFConverter.swift | 31 ++++ macshot/Services/FilenameFormatter.swift | 1 + macshot/Services/HotkeyManager.swift | 10 +- macshot/UI/Overlay/OverlayView+Popovers.swift | 5 +- .../UI/Windows/SettingsWindowController.swift | 10 +- 6 files changed, 177 insertions(+), 19 deletions(-) create mode 100644 macshot/Capture/RecordingGIFConverter.swift diff --git a/macshot/AppDelegate.swift b/macshot/AppDelegate.swift index bed7ac0d9..6614f7705 100644 --- a/macshot/AppDelegate.swift +++ b/macshot/AppDelegate.swift @@ -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) @@ -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 @@ -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) } @@ -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 @@ -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() @@ -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) } @@ -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 @@ -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. @@ -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 @@ -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) } }) diff --git a/macshot/Capture/RecordingGIFConverter.swift b/macshot/Capture/RecordingGIFConverter.swift new file mode 100644 index 000000000..2cdb7d9b4 --- /dev/null +++ b/macshot/Capture/RecordingGIFConverter.swift @@ -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) + } +} diff --git a/macshot/Services/FilenameFormatter.swift b/macshot/Services/FilenameFormatter.swift index 04e41104f..b01aff9a7 100644 --- a/macshot/Services/FilenameFormatter.swift +++ b/macshot/Services/FilenameFormatter.swift @@ -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. /// diff --git a/macshot/Services/HotkeyManager.swift b/macshot/Services/HotkeyManager.swift index 9d8363724..004aad802 100644 --- a/macshot/Services/HotkeyManager.swift +++ b/macshot/Services/HotkeyManager.swift @@ -22,6 +22,7 @@ class HotkeyManager { case captureLastArea = 10 case pinFromClipboard = 11 case clearHistory = 12 + case recordGIF = 13 var keyCodeKey: String { switch self { @@ -37,6 +38,7 @@ class HotkeyManager { case .captureLastArea: return "hotkeyCaptureLastAreaKeyCode" case .pinFromClipboard: return "hotkeyPinClipboardKeyCode" case .clearHistory: return "hotkeyClearHistoryKeyCode" + case .recordGIF: return "hotkeyRecordGIFKeyCode" } } @@ -54,6 +56,7 @@ class HotkeyManager { case .captureLastArea: return "hotkeyCaptureLastAreaModifiers" case .pinFromClipboard: return "hotkeyPinClipboardModifiers" case .clearHistory: return "hotkeyClearHistoryModifiers" + case .recordGIF: return "hotkeyRecordGIFModifiers" } } @@ -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"))" } } @@ -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) } } @@ -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) @@ -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() { diff --git a/macshot/UI/Overlay/OverlayView+Popovers.swift b/macshot/UI/Overlay/OverlayView+Popovers.swift index 3ea7f13ee..2d679b98f 100644 --- a/macshot/UI/Overlay/OverlayView+Popovers.swift +++ b/macshot/UI/Overlay/OverlayView+Popovers.swift @@ -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] } } @@ -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) } diff --git a/macshot/UI/Windows/SettingsWindowController.swift b/macshot/UI/Windows/SettingsWindowController.swift index 11456d93e..7a4230c76 100644 --- a/macshot/UI/Windows/SettingsWindowController.swift +++ b/macshot/UI/Windows/SettingsWindowController.swift @@ -1784,7 +1784,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSWindowD stack.setCustomSpacing(10, after: stack.arrangedSubviews.last!) recordingOnStopPopup = NSPopUpButton() - recordingOnStopPopup.addItems(withTitles: [L("Open editor"), L("Show in Finder"), L("Copy to clipboard")]) + recordingOnStopPopup.addItems(withTitles: [L("Open editor"), L("Show in Finder"), L("Copy to clipboard"), L("Save + copy to clipboard")]) recordingOnStopPopup.target = self recordingOnStopPopup.action = #selector(recordingOnStopChanged(_:)) stack.addArrangedSubview(labeledRow(L("When done:"), controls: [recordingOnStopPopup])) @@ -2738,6 +2738,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSWindowD switch onStop { case "finder": recordingOnStopPopup.selectItem(at: 1) case "clipboard": recordingOnStopPopup.selectItem(at: 2) + case "save": recordingOnStopPopup.selectItem(at: 3) default: recordingOnStopPopup.selectItem(at: 0) } @@ -2945,7 +2946,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSWindowD } @objc private func recordingOnStopChanged(_ sender: NSPopUpButton) { - let values = ["editor", "finder", "clipboard"] + let values = ["editor", "finder", "clipboard", "save"] UserDefaults.standard.set(values[sender.indexOfSelectedItem], forKey: "recordingOnStop") } @objc private func hideRecordingHUDChanged(_ sender: NSButton) { @@ -3208,7 +3209,10 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSWindowD let template = raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? FilenameFormatter.defaultRecordingTemplate : raw let sampleDate = sampleFilenameDate() let sampleIndex = template.contains("{index}") ? 1 : nil - let base = FilenameFormatter.format(template: template, windowTitle: nil, index: sampleIndex, date: sampleDate, fallback: FilenameFormatter.defaultRecordingTemplate) + let base = FilenameFormatter.formatRelativePath( + template: template, appName: "Safari", index: sampleIndex, date: sampleDate, + fallback: FilenameFormatter.defaultRecordingTemplate + ).joined(separator: "/") preview.stringValue = "\(L("Preview:")) \(base).mp4" }