diff --git a/.mise.toml b/.mise.toml index db0d6b1..f29517c 100644 --- a/.mise.toml +++ b/.mise.toml @@ -21,7 +21,7 @@ run = "swiftformat ." [tasks.build] description = "Build the app" -run = "xcodebuild -scheme CaptureThis -configuration Debug -derivedDataPath .build -destination 'platform=macOS' build" +run = ["mise run generate", "xcodebuild -scheme CaptureThis -configuration Debug -derivedDataPath .build -destination 'platform=macOS' build"] [tasks.test] description = "Run unit tests" diff --git a/Sources/App/AppState+GIFExport.swift b/Sources/App/AppState+GIFExport.swift new file mode 100644 index 0000000..998dd23 --- /dev/null +++ b/Sources/App/AppState+GIFExport.swift @@ -0,0 +1,43 @@ +import AppKit +import CaptureThisCore +import Foundation + +extension AppState { + func exportGIF(for recording: Recording) { + Task { @MainActor in + await exportGIF(recording) + } + } + + func exportGIFFromNotification(fileURL: URL) { + let recording = recentRecordings.first { $0.url == fileURL } ?? Recording( + id: UUID(), + url: fileURL, + createdAt: Date(), + duration: nil, + captureType: .display + ) + + Task { @MainActor in + await exportGIF(recording) + } + } + + private func exportGIF(_ recording: Recording) async { + gifExportState = .exporting(recording.id) + errorMessage = nil + + do { + let outputURL = try await withRecordingsDirectoryAccess { + let gifExportService = GIFExportService(policy: GIFExportPolicy(quality: settings.gifExportQuality)) + return try await gifExportService.export(recording: recording) + } + gifExportState = .idle + notificationService.sendGIFExportCompleteNotification(url: outputURL) + revealRecordingURL(outputURL) + } catch { + gifExportState = .idle + errorMessage = error.localizedDescription + } + } +} diff --git a/Sources/App/AppState.swift b/Sources/App/AppState.swift index 53aaea0..b3b60ae 100644 --- a/Sources/App/AppState.swift +++ b/Sources/App/AppState.swift @@ -24,6 +24,7 @@ final class AppState: ObservableObject { @Published var presenterOverlayEnabled = false @Published var recordingStartDate: Date? @Published var permissionSetupState = PermissionSetupState() + @Published var gifExportState: GIFExportState = .idle let engine: RecordingEngine let cameraService = CameraService() @@ -143,4 +144,24 @@ final class AppState: ObservableObject { } } } + + func withRecordingsDirectoryAccess(_ operation: () async throws -> T) async throws -> T { + _ = try await fileAccessService.ensureRecordingsDirectoryAccess() + defer { + fileAccessService.stopAccessingIfNeeded() + } + return try await operation() + } +} + +enum GIFExportState: Equatable { + case idle + case exporting(UUID) + + func isExporting(_ recording: Recording) -> Bool { + if case let .exporting(id) = self { + return id == recording.id + } + return false + } } diff --git a/Sources/Core/Extensions/RecordingSettings+Updating.swift b/Sources/Core/Extensions/RecordingSettings+Updating.swift index 79a0cb8..327d055 100644 --- a/Sources/Core/Extensions/RecordingSettings+Updating.swift +++ b/Sources/Core/Extensions/RecordingSettings+Updating.swift @@ -8,6 +8,7 @@ public extension RecordingSettings { systemAudioEnabled: Bool? = nil, outputFormat: RecordingFileFormat? = nil, recordingQuality: RecordingQuality? = nil, + gifExportQuality: GIFExportQuality? = nil, debugModeEnabled: Bool? = nil ) -> RecordingSettings { var copy = self @@ -29,6 +30,9 @@ public extension RecordingSettings { if let recordingQuality { copy.recordingQuality = recordingQuality } + if let gifExportQuality { + copy.gifExportQuality = gifExportQuality + } if let debugModeEnabled { copy.isDebugModeEnabled = debugModeEnabled } diff --git a/Sources/Core/Models/RecordingSettings.swift b/Sources/Core/Models/RecordingSettings.swift index 159e650..a1fb263 100644 --- a/Sources/Core/Models/RecordingSettings.swift +++ b/Sources/Core/Models/RecordingSettings.swift @@ -36,6 +36,43 @@ public enum RecordingQuality: String, Codable, CaseIterable, Identifiable, Senda } } +public enum GIFExportQuality: String, Codable, CaseIterable, Identifiable, Sendable { + case compact + case balanced + case high + + public var id: String { + rawValue + } + + public var displayName: String { + switch self { + case .compact: + String(localized: "Compact", bundle: .captureThisCore) + case .balanced: + String(localized: "Balanced", bundle: .captureThisCore) + case .high: + String(localized: "High", bundle: .captureThisCore) + } + } + + public var targetWidth: Double { + switch self { + case .compact: 1080 + case .balanced: 1440 + case .high: 2160 + } + } + + public var framesPerSecond: Double { + switch self { + case .compact: 15 + case .balanced: 24 + case .high: 24 + } + } +} + public struct RecordingSettings: Codable, Sendable { public var countdownSeconds: Int public var isCameraEnabled: Bool @@ -43,6 +80,7 @@ public struct RecordingSettings: Codable, Sendable { public var isSystemAudioEnabled: Bool public var outputFormat: RecordingFileFormat public var recordingQuality: RecordingQuality + public var gifExportQuality: GIFExportQuality public var isDebugModeEnabled: Bool public init( @@ -52,6 +90,7 @@ public struct RecordingSettings: Codable, Sendable { isSystemAudioEnabled: Bool = false, outputFormat: RecordingFileFormat = .mp4, recordingQuality: RecordingQuality = .standard, + gifExportQuality: GIFExportQuality = .balanced, isDebugModeEnabled: Bool = false ) { self.countdownSeconds = countdownSeconds @@ -60,6 +99,7 @@ public struct RecordingSettings: Codable, Sendable { self.isSystemAudioEnabled = isSystemAudioEnabled self.outputFormat = outputFormat self.recordingQuality = recordingQuality + self.gifExportQuality = gifExportQuality self.isDebugModeEnabled = isDebugModeEnabled } @@ -76,6 +116,7 @@ public struct RecordingSettings: Codable, Sendable { isSystemAudioEnabled = try value(.isSystemAudioEnabled, defaults.isSystemAudioEnabled) outputFormat = try value(.outputFormat, defaults.outputFormat) recordingQuality = try value(.recordingQuality, defaults.recordingQuality) + gifExportQuality = try value(.gifExportQuality, defaults.gifExportQuality) isDebugModeEnabled = try value(.isDebugModeEnabled, defaults.isDebugModeEnabled) } } diff --git a/Sources/Core/Models/RecordingState.swift b/Sources/Core/Models/RecordingState.swift index 3872e45..306c712 100644 --- a/Sources/Core/Models/RecordingState.swift +++ b/Sources/Core/Models/RecordingState.swift @@ -9,7 +9,9 @@ public enum RecordingState: Equatable, Sendable { case error(String) public var isRecording: Bool { - if case .recording = self { return true } + if case .recording = self { + return true + } return false } diff --git a/Sources/Core/RecordingEngine+Flow.swift b/Sources/Core/RecordingEngine+Flow.swift index ad43d78..4997818 100644 --- a/Sources/Core/RecordingEngine+Flow.swift +++ b/Sources/Core/RecordingEngine+Flow.swift @@ -84,12 +84,16 @@ extension RecordingEngine { func runCountdown(total: Int, filter: SCContentFilter) async { for remaining in stride(from: total, through: 1, by: -1) { - if Task.isCancelled { return } + if Task.isCancelled { + return + } setState(.countdown(remaining)) try? await Task.sleep(nanoseconds: 1_000_000_000) } - if Task.isCancelled { return } + if Task.isCancelled { + return + } await beginRecording(with: filter) } @@ -207,12 +211,16 @@ extension RecordingEngine { if settings.isCameraEnabled { let granted = await permissionService.requestCameraAccess() - if !granted { throw AppError.permissionDenied } + if !granted { + throw AppError.permissionDenied + } } if settings.isMicrophoneEnabled { let granted = await permissionService.requestMicrophoneAccess() - if !granted { throw AppError.permissionDenied } + if !granted { + throw AppError.permissionDenied + } } } diff --git a/Sources/Core/Services/GIFExportService.swift b/Sources/Core/Services/GIFExportService.swift new file mode 100644 index 0000000..ceb99a9 --- /dev/null +++ b/Sources/Core/Services/GIFExportService.swift @@ -0,0 +1,178 @@ +import AVFoundation +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +public struct GIFExportPolicy: Equatable, Sendable { + public let maxDuration: TimeInterval + public let targetWidth: CGFloat + public let targetFramesPerSecond: Double + + public static let standard = GIFExportPolicy( + maxDuration: 10, + targetWidth: 720, + targetFramesPerSecond: 12 + ) + + public init( + maxDuration: TimeInterval, + targetWidth: CGFloat, + targetFramesPerSecond: Double + ) { + self.maxDuration = maxDuration + self.targetWidth = targetWidth + self.targetFramesPerSecond = targetFramesPerSecond + } + + public init(quality: GIFExportQuality) { + self.init( + maxDuration: Self.standard.maxDuration, + targetWidth: CGFloat(quality.targetWidth), + targetFramesPerSecond: quality.framesPerSecond + ) + } +} + +public enum GIFExportError: LocalizedError, Equatable { + case durationUnavailable + case clipTooLong(duration: TimeInterval, maxDuration: TimeInterval) + case noVideoTrack + case imageDestinationUnavailable + case imageEncodingFailed + + public var errorDescription: String? { + switch self { + case .durationUnavailable: + "Unable to read the recording duration." + case let .clipTooLong(duration, maxDuration): + "GIF export supports clips up to \(Self.format(maxDuration)); this recording is \(Self.format(duration))." + case .noVideoTrack: + "The recording does not contain a video track." + case .imageDestinationUnavailable: + "Unable to create the GIF file." + case .imageEncodingFailed: + "Unable to encode the GIF." + } + } + + private static func format(_ duration: TimeInterval) -> String { + let rounded = (duration * 10).rounded() / 10 + return "\(rounded)s" + } +} + +public final class GIFExportService { + private let fileManager: FileManager + private let policy: GIFExportPolicy + + public init(fileManager: FileManager = .default, policy: GIFExportPolicy = .standard) { + self.fileManager = fileManager + self.policy = policy + } + + public func export(recording: Recording) async throws -> URL { + let asset = AVURLAsset(url: recording.url) + let duration = try await resolvedDuration(for: recording, asset: asset) + try validate(duration: duration) + + let outputURL = uniqueDestinationURL(for: recording.url) + let frameCount = max(1, Int((duration * policy.targetFramesPerSecond).rounded(.up))) + let frameDelay = 1 / policy.targetFramesPerSecond + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.requestedTimeToleranceBefore = .zero + generator.requestedTimeToleranceAfter = .zero + + let size = try await scaledVideoSize(for: asset) + generator.maximumSize = size + + guard let destination = CGImageDestinationCreateWithURL( + outputURL as CFURL, + UTType.gif.identifier as CFString, + frameCount, + nil + ) else { + throw GIFExportError.imageDestinationUnavailable + } + + CGImageDestinationSetProperties(destination, [ + kCGImagePropertyGIFDictionary: [ + kCGImagePropertyGIFLoopCount: 0 + ] + ] as CFDictionary) + + let frameProperties = [ + kCGImagePropertyGIFDictionary: [ + kCGImagePropertyGIFDelayTime: frameDelay + ] + ] as CFDictionary + + for frameIndex in 0 ..< frameCount { + let seconds = min(Double(frameIndex) / policy.targetFramesPerSecond, max(duration - 0.001, 0)) + let time = CMTime(seconds: seconds, preferredTimescale: 600) + let image = try generator.copyCGImage(at: time, actualTime: nil) + CGImageDestinationAddImage(destination, image, frameProperties) + } + + guard CGImageDestinationFinalize(destination) else { + try? fileManager.removeItem(at: outputURL) + throw GIFExportError.imageEncodingFailed + } + + return outputURL + } + + public func uniqueDestinationURL(for videoURL: URL) -> URL { + let directory = videoURL.deletingLastPathComponent() + let baseName = videoURL.deletingPathExtension().lastPathComponent + var candidate = directory.appendingPathComponent(baseName).appendingPathExtension("gif") + var index = 2 + + while fileManager.fileExists(atPath: candidate.path) { + candidate = directory + .appendingPathComponent("\(baseName)-\(index)") + .appendingPathExtension("gif") + index += 1 + } + + return candidate + } + + private func resolvedDuration(for recording: Recording, asset: AVURLAsset) async throws -> TimeInterval { + if let duration = recording.duration, duration > 0 { + return duration + } + + let assetDuration = try await asset.load(.duration) + guard assetDuration.isNumeric, assetDuration.seconds > 0 else { + throw GIFExportError.durationUnavailable + } + return assetDuration.seconds + } + + private func validate(duration: TimeInterval) throws { + guard duration <= policy.maxDuration else { + throw GIFExportError.clipTooLong(duration: duration, maxDuration: policy.maxDuration) + } + } + + private func scaledVideoSize(for asset: AVURLAsset) async throws -> CGSize { + guard let track = try await asset.loadTracks(withMediaType: .video).first else { + throw GIFExportError.noVideoTrack + } + + let naturalSize = try await track.load(.naturalSize) + let transform = try await track.load(.preferredTransform) + let transformed = naturalSize.applying(transform) + let width = abs(transformed.width) + let height = abs(transformed.height) + + guard width > 0, height > 0 else { + throw GIFExportError.noVideoTrack + } + + let scale = min(1, policy.targetWidth / width) + return CGSize(width: (width * scale).rounded(), height: (height * scale).rounded()) + } +} diff --git a/Sources/Core/Services/RecordingStore.swift b/Sources/Core/Services/RecordingStore.swift index 893ff8e..8e25779 100644 --- a/Sources/Core/Services/RecordingStore.swift +++ b/Sources/Core/Services/RecordingStore.swift @@ -10,7 +10,9 @@ public enum RecordingStore { do { let recordings = try JSONDecoder().decode([Recording].self, from: data) let existing = recordings.filter { FileManager.default.fileExists(atPath: $0.url.path) } - if existing.count != recordings.count { save(existing) } + if existing.count != recordings.count { + save(existing) + } return existing } catch { return [] diff --git a/Sources/Features/MenuBar/MenuBarView.swift b/Sources/Features/MenuBar/MenuBarView.swift index 93adc0f..351875f 100644 --- a/Sources/Features/MenuBar/MenuBarView.swift +++ b/Sources/Features/MenuBar/MenuBarView.swift @@ -219,6 +219,8 @@ private struct RecordingRow: View { Menu { Button("Open") { appState.openRecording(recording) } Button("Reveal in Finder") { appState.revealRecording(recording) } + Button(gifExportTitle) { appState.exportGIF(for: recording) } + .disabled(appState.gifExportState.isExporting(recording)) } label: { Image(systemName: "ellipsis") .font(.system(size: 12)) @@ -246,10 +248,18 @@ private struct RecordingRow: View { Button("Reveal in Finder") { appState.revealRecording(recording) } + Button(gifExportTitle) { appState.exportGIF(for: recording) } + .disabled(appState.gifExportState.isExporting(recording)) } .help("Open \(recording.url.lastPathComponent)") } + private var gifExportTitle: String { + appState.gifExportState.isExporting(recording) + ? String(localized: "Exporting GIF…") + : String(localized: "Export as GIF") + } + private var subtitle: String { var parts = [Self.relativeDate(recording.createdAt)] if let duration = recording.duration { diff --git a/Sources/Features/Settings/SettingsView.swift b/Sources/Features/Settings/SettingsView.swift index c325304..acd530a 100644 --- a/Sources/Features/Settings/SettingsView.swift +++ b/Sources/Features/Settings/SettingsView.swift @@ -85,7 +85,7 @@ struct SettingsView: View { } } - Picker("Quality", selection: Binding( + Picker("Video Quality", selection: Binding( get: { appState.settings.recordingQuality }, set: { appState.updateSettings(appState.settings.updating(recordingQuality: $0)) } )) { @@ -93,6 +93,15 @@ struct SettingsView: View { Text(quality.displayName).tag(quality) } } + + Picker("GIF Quality", selection: Binding( + get: { appState.settings.gifExportQuality }, + set: { appState.updateSettings(appState.settings.updating(gifExportQuality: $0)) } + )) { + ForEach(GIFExportQuality.allCases) { quality in + Text(quality.displayName).tag(quality) + } + } } Section { diff --git a/Sources/Services/NotificationService.swift b/Sources/Services/NotificationService.swift index 14ee1ee..6c678ae 100644 --- a/Sources/Services/NotificationService.swift +++ b/Sources/Services/NotificationService.swift @@ -52,6 +52,26 @@ final class NotificationService: NSObject { } } + func sendGIFExportCompleteNotification(url: URL) { + Task { + guard await authorizationStatus().isGranted else { return } + + let content = UNMutableNotificationContent() + content.title = String(localized: "GIF export complete") + content.body = url.lastPathComponent + content.categoryIdentifier = "gif.complete" + content.userInfo = ["fileURL": url.absoluteString] + + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil + ) + + try? await center.add(request) + } + } + private func registerCategories() { let openAction = UNNotificationAction( identifier: "recording.open", @@ -65,14 +85,27 @@ final class NotificationService: NSObject { options: [.foreground] ) - let category = UNNotificationCategory( + let exportGIFAction = UNNotificationAction( + identifier: "recording.exportGIF", + title: String(localized: "Export as GIF"), + options: [.foreground] + ) + + let recordingCategory = UNNotificationCategory( identifier: "recording.complete", + actions: [openAction, revealAction, exportGIFAction], + intentIdentifiers: [], + options: [] + ) + + let gifCategory = UNNotificationCategory( + identifier: "gif.complete", actions: [openAction, revealAction], intentIdentifiers: [], options: [] ) - center.setNotificationCategories([category]) + center.setNotificationCategories([recordingCategory, gifCategory]) } } @@ -93,6 +126,8 @@ extension NotificationService: UNUserNotificationCenterDelegate { await MainActor.run { AppState.shared.revealRecordingURL(url) } + case "recording.exportGIF": + await AppState.shared.exportGIFFromNotification(fileURL: url) default: break } diff --git a/Tests/CoreTests/GIFExportServiceTests.swift b/Tests/CoreTests/GIFExportServiceTests.swift new file mode 100644 index 0000000..e8dc8e4 --- /dev/null +++ b/Tests/CoreTests/GIFExportServiceTests.swift @@ -0,0 +1,55 @@ +@testable import CaptureThisCore +import XCTest + +final class GIFExportServiceTests: XCTestCase { + func testStandardPolicyMatchesProductLimits() { + let policy = GIFExportPolicy.standard + + XCTAssertEqual(policy.maxDuration, 10) + XCTAssertEqual(policy.targetWidth, 720) + XCTAssertEqual(policy.targetFramesPerSecond, 12) + } + + func testQualityPolicyMappings() { + let compact = GIFExportPolicy(quality: .compact) + XCTAssertEqual(compact.maxDuration, 10) + XCTAssertEqual(compact.targetWidth, 1080) + XCTAssertEqual(compact.targetFramesPerSecond, 15) + + let balanced = GIFExportPolicy(quality: .balanced) + XCTAssertEqual(balanced.targetWidth, 1440) + XCTAssertEqual(balanced.targetFramesPerSecond, 24) + + let high = GIFExportPolicy(quality: .high) + XCTAssertEqual(high.targetWidth, 2160) + XCTAssertEqual(high.targetFramesPerSecond, 24) + } + + func testUniqueDestinationURLUsesOriginalNameWithGIFExtension() { + let tempDir = makeTemporaryDirectory() + let videoURL = tempDir.appendingPathComponent("recording.mp4") + let service = GIFExportService(policy: .standard) + + let destination = service.uniqueDestinationURL(for: videoURL) + + XCTAssertEqual(destination, tempDir.appendingPathComponent("recording.gif")) + } + + func testUniqueDestinationURLAvoidsExistingFiles() throws { + let tempDir = makeTemporaryDirectory() + let videoURL = tempDir.appendingPathComponent("recording.mp4") + try Data().write(to: tempDir.appendingPathComponent("recording.gif")) + try Data().write(to: tempDir.appendingPathComponent("recording-2.gif")) + let service = GIFExportService(policy: .standard) + + let destination = service.uniqueDestinationURL(for: videoURL) + + XCTAssertEqual(destination, tempDir.appendingPathComponent("recording-3.gif")) + } + + private func makeTemporaryDirectory() -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } +} diff --git a/Tests/CoreTests/RecordingEnginePauseResumeTests.swift b/Tests/CoreTests/RecordingEnginePauseResumeTests.swift index 5ff42c3..fc49e96 100644 --- a/Tests/CoreTests/RecordingEnginePauseResumeTests.swift +++ b/Tests/CoreTests/RecordingEnginePauseResumeTests.swift @@ -199,7 +199,9 @@ final class RecordingEnginePauseResumeTests: XCTestCase { ) async { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { - if check() { return } + if check() { + return + } try? await Task.sleep(nanoseconds: 10_000_000) } XCTFail("Timed out waiting for condition") diff --git a/Tests/CoreTests/RecordingSettingsTests.swift b/Tests/CoreTests/RecordingSettingsTests.swift index 2e505f4..329b72d 100644 --- a/Tests/CoreTests/RecordingSettingsTests.swift +++ b/Tests/CoreTests/RecordingSettingsTests.swift @@ -10,14 +10,21 @@ final class RecordingSettingsTests: XCTestCase { XCTAssertFalse(settings.isSystemAudioEnabled) XCTAssertEqual(settings.outputFormat, .mp4) XCTAssertEqual(settings.recordingQuality, .standard) + XCTAssertEqual(settings.gifExportQuality, .balanced) } func testUpdating() { let settings = RecordingSettings() - let updated = settings.updating(countdownSeconds: 5, cameraEnabled: false, recordingQuality: .high) + let updated = settings.updating( + countdownSeconds: 5, + cameraEnabled: false, + recordingQuality: .high, + gifExportQuality: .compact + ) XCTAssertEqual(updated.countdownSeconds, 5) XCTAssertFalse(updated.isCameraEnabled) XCTAssertEqual(updated.recordingQuality, .high) + XCTAssertEqual(updated.gifExportQuality, .compact) // Unchanged fields XCTAssertTrue(updated.isMicrophoneEnabled) XCTAssertEqual(updated.outputFormat, .mp4)