From: Ruben Beltran del Rio Date: Fri, 28 Jul 2023 11:48:53 +0000 (+0200) Subject: Add clipboard X-Git-Tag: 1.0.0~10 X-Git-Url: https://git.r.bdr.sh/rbdr/captura/commitdiff_plain/c9b9e1d654ea697afad9f6427d94623bfdf55cce?ds=inline;hp=f5d16c1c8c259966338b23afd407d142f72784aa Add clipboard --- diff --git a/Captura.xcodeproj/project.pbxproj b/Captura.xcodeproj/project.pbxproj index a506dc5..3238749 100644 --- a/Captura.xcodeproj/project.pbxproj +++ b/Captura.xcodeproj/project.pbxproj @@ -15,6 +15,9 @@ B5278B282A739871009F6462 /* CGImage+resize.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B272A739871009F6462 /* CGImage+resize.swift */; }; B5278B2A2A73992D009F6462 /* GifRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B292A73992D009F6462 /* GifRenderer.swift */; }; B5278B2C2A739B3A009F6462 /* CapturaFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B2B2A739B3A009F6462 /* CapturaFile.swift */; }; + B5278B312A73AEAE009F6462 /* CVImageBuffer+cgImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B302A73AEAE009F6462 /* CVImageBuffer+cgImage.swift */; }; + B5278B362A73B3AA009F6462 /* CapturaCaptureSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B352A73B3AA009F6462 /* CapturaCaptureSession.swift */; }; + B5278B382A73D1EE009F6462 /* CapturaSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B372A73D1EE009F6462 /* CapturaSettings.swift */; }; B55DDFCC2A6F0253001A5E76 /* Notification+AppEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55DDFCB2A6F0253001A5E76 /* Notification+AppEvents.swift */; }; B55DDFCE2A6F069D001A5E76 /* RecordingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55DDFCD2A6F069D001A5E76 /* RecordingWindow.swift */; }; B56C70CD2A6EFDF4009B97EB /* CaptureState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */; }; @@ -22,7 +25,7 @@ B5F915542A6EF80D007ECE8E /* PreferencesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F915532A6EF80D007ECE8E /* PreferencesScreen.swift */; }; B5F915562A6EF80E007ECE8E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5F915552A6EF80E007ECE8E /* Assets.xcassets */; }; B5F915592A6EF80E007ECE8E /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5F915582A6EF80E007ECE8E /* Preview Assets.xcassets */; }; - B5F9155B2A6EF80E007ECE8E /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F9155A2A6EF80E007ECE8E /* Item.swift */; }; + B5F9155B2A6EF80E007ECE8E /* CapturaRemoteFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F9155A2A6EF80E007ECE8E /* CapturaRemoteFile.swift */; }; B5F915662A6EF80E007ECE8E /* CapturaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F915652A6EF80E007ECE8E /* CapturaTests.swift */; }; B5F915702A6EF80E007ECE8E /* CapturaUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F9156F2A6EF80E007ECE8E /* CapturaUITests.swift */; }; B5F915722A6EF80E007ECE8E /* CapturaUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F915712A6EF80E007ECE8E /* CapturaUITestsLaunchTests.swift */; }; @@ -54,6 +57,9 @@ B5278B272A739871009F6462 /* CGImage+resize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGImage+resize.swift"; sourceTree = ""; }; B5278B292A73992D009F6462 /* GifRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifRenderer.swift; sourceTree = ""; }; B5278B2B2A739B3A009F6462 /* CapturaFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturaFile.swift; sourceTree = ""; }; + B5278B302A73AEAE009F6462 /* CVImageBuffer+cgImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CVImageBuffer+cgImage.swift"; sourceTree = ""; }; + B5278B352A73B3AA009F6462 /* CapturaCaptureSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturaCaptureSession.swift; sourceTree = ""; }; + B5278B372A73D1EE009F6462 /* CapturaSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturaSettings.swift; sourceTree = ""; }; B55DDFCB2A6F0253001A5E76 /* Notification+AppEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+AppEvents.swift"; sourceTree = ""; }; B55DDFCD2A6F069D001A5E76 /* RecordingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingWindow.swift; sourceTree = ""; }; B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureState.swift; sourceTree = ""; }; @@ -62,7 +68,7 @@ B5F915532A6EF80D007ECE8E /* PreferencesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesScreen.swift; sourceTree = ""; }; B5F915552A6EF80E007ECE8E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; B5F915582A6EF80E007ECE8E /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; - B5F9155A2A6EF80E007ECE8E /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = ""; }; + B5F9155A2A6EF80E007ECE8E /* CapturaRemoteFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturaRemoteFile.swift; sourceTree = ""; }; B5F9155C2A6EF80E007ECE8E /* Captura.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Captura.entitlements; sourceTree = ""; }; B5F915612A6EF80E007ECE8E /* CapturaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CapturaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; B5F915652A6EF80E007ECE8E /* CapturaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturaTests.swift; sourceTree = ""; }; @@ -118,7 +124,6 @@ B5278B1A2A71BD1F009F6462 /* Windows */ = { isa = PBXGroup; children = ( - B5F915512A6EF80D007ECE8E /* CapturaApp.swift */, B55DDFCD2A6F069D001A5E76 /* RecordingWindow.swift */, B5278B222A71C140009F6462 /* PreferencesWindow.swift */, ); @@ -136,10 +141,11 @@ B5278B1C2A71BD3C009F6462 /* Data */ = { isa = PBXGroup; children = ( - B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */, B5278B202A71BFC3009F6462 /* SettingsStructs.swift */, B5278B292A73992D009F6462 /* GifRenderer.swift */, B5278B2B2A739B3A009F6462 /* CapturaFile.swift */, + B5F9155A2A6EF80E007ECE8E /* CapturaRemoteFile.swift */, + B5278B372A73D1EE009F6462 /* CapturaSettings.swift */, ); path = Data; sourceTree = ""; @@ -158,10 +164,20 @@ children = ( B55DDFCB2A6F0253001A5E76 /* Notification+AppEvents.swift */, B5278B272A739871009F6462 /* CGImage+resize.swift */, + B5278B302A73AEAE009F6462 /* CVImageBuffer+cgImage.swift */, ); path = "Core Extensions"; sourceTree = ""; }; + B5278B322A73AFEC009F6462 /* Domain */ = { + isa = PBXGroup; + children = ( + B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */, + B5278B352A73B3AA009F6462 /* CapturaCaptureSession.swift */, + ); + path = Domain; + sourceTree = ""; + }; B5F915452A6EF80D007ECE8E = { isa = PBXGroup; children = ( @@ -185,11 +201,12 @@ B5F915502A6EF80D007ECE8E /* Captura */ = { isa = PBXGroup; children = ( + B5F915512A6EF80D007ECE8E /* CapturaApp.swift */, B5278B262A739862009F6462 /* Core Extensions */, B5278B1C2A71BD3C009F6462 /* Data */, + B5278B322A73AFEC009F6462 /* Domain */, B5278B182A71BD10009F6462 /* Presentation */, B5F915552A6EF80E007ECE8E /* Assets.xcassets */, - B5F9155A2A6EF80E007ECE8E /* Item.swift */, B5F9155C2A6EF80E007ECE8E /* Captura.entitlements */, B5F915572A6EF80E007ECE8E /* Preview Content */, ); @@ -352,6 +369,7 @@ buildActionMask = 2147483647; files = ( B5F915542A6EF80D007ECE8E /* PreferencesScreen.swift in Sources */, + B5278B382A73D1EE009F6462 /* CapturaSettings.swift in Sources */, B5278B282A739871009F6462 /* CGImage+resize.swift in Sources */, B5278B2C2A739B3A009F6462 /* CapturaFile.swift in Sources */, B5278B1F2A71BD9B009F6462 /* OutputSettings.swift in Sources */, @@ -359,11 +377,13 @@ B5278B212A71BFC3009F6462 /* SettingsStructs.swift in Sources */, B55DDFCE2A6F069D001A5E76 /* RecordingWindow.swift in Sources */, B55DDFCC2A6F0253001A5E76 /* Notification+AppEvents.swift in Sources */, - B5F9155B2A6EF80E007ECE8E /* Item.swift in Sources */, + B5F9155B2A6EF80E007ECE8E /* CapturaRemoteFile.swift in Sources */, B5278B232A71C140009F6462 /* PreferencesWindow.swift in Sources */, B5278B172A71528F009F6462 /* HelpPopoverViewController.swift in Sources */, B56C70CD2A6EFDF4009B97EB /* CaptureState.swift in Sources */, + B5278B312A73AEAE009F6462 /* CVImageBuffer+cgImage.swift in Sources */, B5278B252A71CA80009F6462 /* AdvancedSettings.swift in Sources */, + B5278B362A73B3AA009F6462 /* CapturaCaptureSession.swift in Sources */, B5F915522A6EF80D007ECE8E /* CapturaApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Captura/Presentation/Windows/CapturaApp.swift b/Captura/CapturaApp.swift similarity index 54% rename from Captura/Presentation/Windows/CapturaApp.swift rename to Captura/CapturaApp.swift index 6cf7044..4f1ceff 100644 --- a/Captura/Presentation/Windows/CapturaApp.swift +++ b/Captura/CapturaApp.swift @@ -2,7 +2,7 @@ import SwiftUI import SwiftData import Cocoa import Combine -import ReplayKit +import AVFoundation @main struct CapturaApp: App { @@ -16,11 +16,11 @@ struct CapturaApp: App { .frame(width: 650, height: 450) } .handlesExternalEvents(matching: Set(arrayLiteral: "PreferencesScreen")) - .modelContainer(for: Item.self) + .modelContainer(for: CapturaRemoteFile.self) } } -class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureFileOutputRecordingDelegate, NSMenuDelegate { +class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { @Environment(\.openURL) var openURL var statusItem: NSStatusItem! @@ -30,12 +30,11 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut var boxListener: AnyCancellable? = nil var popover: NSPopover? = nil var helpShown = false - var receivedFrames = false - var captureSession: AVCaptureSession? = nil + var captureSession: CapturaCaptureSession? = nil var images: [CGImage] = [] var outputFile: CapturaFile? = nil var gifCallbackTimer = ContinuousClock.now - var fps = UserDefaults.standard.integer(forKey: "frameRate") + var fps = CapturaSettings.frameRate var pixelDensity: CGFloat = 1.0 var stopTimer: DispatchWorkItem? @@ -131,18 +130,19 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut } case .reset: reset() + case .failedToStart: + failedToStart() + case .receivedFrame: + if let frame = notification.userInfo?["frame"] { + receivedFrame(frame as! CVImageBuffer) + } default: return } - /* - if let data = notification.userInfo?["data"] as? String { - print("Data received: \(data)") - } - */ } - @objc func startAreaSelection() { + func startAreaSelection() { helpShown = false NSApp.activate(ignoringOtherApps: true) if captureState != .selectingArea { @@ -151,105 +151,59 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut let rectInWindow = button.convert(button.bounds, to: nil) let rectInScreen = button.window?.convertToScreen(rectInWindow) recordingWindow = RecordingWindow(rectInScreen) - if let view = recordingWindow?.contentView as? RecordingContentView { - boxListener = view.$box - .debounce(for: .seconds(0.3), scheduler: RunLoop.main) - .sink { newValue in - if newValue != nil { - button.image = NSImage(systemSymbolName: "circle.rectangle.dashed", accessibilityDescription: "Captura") - if !self.helpShown { - self.helpShown = true - self.showPopoverWithMessage("Click here when you're ready to record.") - } + boxListener = recordingWindow?.recordingContentView.$box + .debounce(for: .seconds(0.3), scheduler: RunLoop.main) + .sink { newValue in + if newValue != nil { + self.updateImage() + if !self.helpShown { + self.helpShown = true + self.showPopoverWithMessage("Click here when you're ready to record.") } } - } + } } } } func startRecording() { captureState = .recording - fps = UserDefaults.standard.integer(forKey: "frameRate") + updateImage() + fps = CapturaSettings.frameRate outputFile = nil images = []; pixelDensity = recordingWindow?.pixelDensity ?? 1.0 - if let view = recordingWindow?.contentView as? RecordingContentView { - view.startRecording() - if let box = view.box { - if let screen = NSScreen.main { - let displayId = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! CGDirectDisplayID - let screenInput = AVCaptureScreenInput(displayID: displayId) - screenInput?.cropRect = box.insetBy(dx: 1, dy: 1) - - captureSession = AVCaptureSession() + recordingWindow?.recordingContentView.startRecording() + if let box = recordingWindow?.recordingContentView.box { + if let screen = recordingWindow?.screen { + captureSession = CapturaCaptureSession(screen, box: box) + + if let captureSession { + + stopTimer = DispatchWorkItem { + self.stopRecording() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 300, execute: stopTimer!) - if let captureSession { - - - if captureSession.canAddInput(screenInput!) { - captureSession.addInput(screenInput!) - } - - let videoOutput = AVCaptureVideoDataOutput() - videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "sample buffer delegate", attributes: [])) - - if captureSession.canAddOutput(videoOutput) { - captureSession.addOutput(videoOutput) - } - - let movieFileOutput = AVCaptureMovieFileOutput() - if captureSession.canAddOutput(movieFileOutput) { - captureSession.addOutput(movieFileOutput) - } - - stopTimer = DispatchWorkItem { - self.stopRecording() - } - DispatchQueue.main.asyncAfter(deadline: .now() + 300, execute: stopTimer!) - - if let button = statusItem.button { - button.image = NSImage(systemSymbolName: "checkmark.rectangle", accessibilityDescription: "Captura") - } - - receivedFrames = false + outputFile = CapturaFile() + if CapturaSettings.shouldSaveMp4 { + captureSession.startRecording(to: outputFile!.mp4URL) + } else { captureSession.startRunning() - - outputFile = CapturaFile() - let outputFormatsSetting = OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "outputFormats")) ?? .all - if outputFormatsSetting.shouldSaveMp4() { - movieFileOutput.startRecording(to: outputFile!.mp4URL, recordingDelegate: self) - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - if !self.receivedFrames { - self.requestPermission() - } - } } - - - } else { - print("Should error") + return } } } + NotificationCenter.default.post(name: .failedToStart, object: nil, userInfo: nil) } func stopRecording() { - if let button = statusItem.button { - button.image = NSImage(systemSymbolName: "dock.arrow.up.rectangle", accessibilityDescription: "Captura") - } - stopTimer?.cancel() captureState = .uploading - captureSession?.stopRunning() - captureSession = nil - boxListener?.cancel() - recordingWindow?.close() - self.recordingWindow = nil + updateImage() + stop() - let outputFormatsSetting = OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "outputFormats")) ?? .all - if !outputFormatsSetting.shouldSaveGif() { + if !CapturaSettings.shouldSaveMp4 { NotificationCenter.default.post(name: .finalizeRecording, object: nil, userInfo: nil) return } @@ -263,56 +217,53 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut } func finalizeRecording() { - if let button = statusItem.button { - button.image = NSImage(systemSymbolName: "checkmark.rectangle.fill", accessibilityDescription: "Captura") - } captureState = .uploaded + copyToClipboard() + updateImage() DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - self.reset() + NotificationCenter.default.post(name: .reset, object: nil, userInfo: nil) } } func reset() { - if let button = statusItem.button { - button.image = NSImage(systemSymbolName: "rectangle.dashed.badge.record", accessibilityDescription: "Captura") - } captureState = .idle - stopTimer?.cancel() - captureSession?.stopRunning() - boxListener?.cancel() - recordingWindow?.close() - self.recordingWindow = nil - } - - private func requestPermission() { - reset() - showPopoverWithMessage("Please grant Captura permission to record") - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording") { - NSWorkspace.shared.open(url) - } + updateImage() + stop() } - func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { - receivedFrames = true - + func receivedFrame(_ frame: CVImageBuffer) { let now = ContinuousClock.now if now - gifCallbackTimer > .nanoseconds(1_000_000_000 / UInt64(fps)) { gifCallbackTimer = now DispatchQueue.main.async { - // Get the CVImageBuffer from the sample buffer - guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } - let ciImage = CIImage(cvImageBuffer: imageBuffer) - let context = CIContext() - if let cgImage = context.createCGImage(ciImage, from: CGRect(x: 0, y: 0, width: CVPixelBufferGetWidth(imageBuffer), height: CVPixelBufferGetHeight(imageBuffer))) { - if let cgImage = cgImage.resize(by: self.pixelDensity) { - self.images.append(cgImage) - } + if let cgImage = frame.cgImage?.resize(by: self.pixelDensity) { + self.images.append(cgImage) } } } } + func failedToStart() { + captureState = .error + updateImage() + requestPermissionToRecord() + stop() + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + NotificationCenter.default.post(name: .reset, object: nil, userInfo: nil) + } + } + + // MARK: - Presentation Helpers + + + private func requestPermissionToRecord() { + showPopoverWithMessage("Please grant Captura permission to record") + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording") { + NSWorkspace.shared.open(url) + } + } + private func showPopoverWithMessage(_ message: String) { if let button = statusItem.button { (self.popover?.contentViewController as? HelpPopoverViewController)?.updateLabel(message) @@ -323,7 +274,43 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut } } - // MARK: - AVCaptureFileOutputRecordingDelegate Implementation + private func updateImage() { + if let button = statusItem.button { + let image: String = switch captureState { + case .idle: + "rectangle.dashed.badge.record" + case .selectingArea: + "circle.rectangle.dashed" + case .recording: + "checkmark.rectangle" + case .uploading: + "dock.arrow.up.rectangle" + case .uploaded: + "checkmark.rectangle.fill" + case .error: + "xmark.rectangle.fill" + } + button.image = NSImage(systemSymbolName: image, accessibilityDescription: "Captura") + } + } + + private func stop() { + stopTimer?.cancel() + captureSession?.stopRunning() + captureSession = nil + boxListener?.cancel() + recordingWindow?.close() + recordingWindow = nil + } - func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {} + private func copyToClipboard() { + let fileType: NSPasteboard.PasteboardType = .init(rawValue: CapturaSettings.shouldSaveGif ? "com.compuserve.gif" : "public.mpeg-4") + if let url = CapturaSettings.shouldSaveGif ? outputFile?.gifURL : outputFile?.mp4URL { + if let data = try? Data(contentsOf: url) { + let pasteboard = NSPasteboard.general + pasteboard.declareTypes([fileType], owner: nil) + pasteboard.setData(data, forType: fileType) + } + } + } } diff --git a/Captura/Core Extensions/CVImageBuffer+cgImage.swift b/Captura/Core Extensions/CVImageBuffer+cgImage.swift new file mode 100644 index 0000000..c30cb0c --- /dev/null +++ b/Captura/Core Extensions/CVImageBuffer+cgImage.swift @@ -0,0 +1,11 @@ +import Foundation +import ReplayKit + +extension CVImageBuffer { + + var cgImage: CGImage? { + let ciImage = CIImage(cvImageBuffer: self) + let context = CIContext() + return context.createCGImage(ciImage, from: CGRect(x: 0, y: 0, width: CVPixelBufferGetWidth(self), height: CVPixelBufferGetHeight(self))) + } +} diff --git a/Captura/Core Extensions/Notification+AppEvents.swift b/Captura/Core Extensions/Notification+AppEvents.swift index 809c00e..5d00937 100644 --- a/Captura/Core Extensions/Notification+AppEvents.swift +++ b/Captura/Core Extensions/Notification+AppEvents.swift @@ -6,4 +6,6 @@ extension Notification.Name { static let stopRecording = Notification.Name("stopRecording") static let finalizeRecording = Notification.Name("finalizeRecording") static let reset = Notification.Name("reset") + static let failedToStart = Notification.Name("failedToStart") + static let receivedFrame = Notification.Name("receivedFrame") } diff --git a/Captura/Data/CapturaRemoteFile.swift b/Captura/Data/CapturaRemoteFile.swift new file mode 100644 index 0000000..24d821e --- /dev/null +++ b/Captura/Data/CapturaRemoteFile.swift @@ -0,0 +1,13 @@ +import Foundation +import SwiftData + +@Model +final class CapturaRemoteFile { + var timestamp: Date + var url: URL + + init(url: URL) { + self.timestamp = Date() + self.url = url + } +} diff --git a/Captura/Data/CapturaSettings.swift b/Captura/Data/CapturaSettings.swift new file mode 100644 index 0000000..d0d04f2 --- /dev/null +++ b/Captura/Data/CapturaSettings.swift @@ -0,0 +1,29 @@ +import Foundation + +struct CapturaSettings { + static var frameRate: Int { + UserDefaults.standard.integer(forKey: "frameRate") + } + + static var outputFormats: OutputFormatSetting { + OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "outputFormats")) ?? .all + } + + static var shouldSaveMp4: Bool { + outputFormats.shouldSaveMp4() + } + + static var shouldSaveGif: Bool { + outputFormats.shouldSaveGif() + } + + + static var shouldSendNotifications: Bool { + get { + UserDefaults.standard.bool(forKey: "shouldSendNotifications") + } + set { + UserDefaults.standard.setValue(newValue, forKey: "shouldSendNotifications") + } + } +} diff --git a/Captura/Domain/CapturaCaptureSession.swift b/Captura/Domain/CapturaCaptureSession.swift new file mode 100644 index 0000000..80240e6 --- /dev/null +++ b/Captura/Domain/CapturaCaptureSession.swift @@ -0,0 +1,60 @@ +import AppKit +import AVFoundation + +class CapturaCaptureSession: AVCaptureSession, AVCaptureFileOutputRecordingDelegate, AVCaptureVideoDataOutputSampleBufferDelegate { + + let videoOutput = AVCaptureVideoDataOutput() + let movieFileOutput = AVCaptureMovieFileOutput() + var receivedFrames = false + + init(_ screen: NSScreen, box: NSRect) { + super.init() + + let displayId = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! CGDirectDisplayID + let screenInput = AVCaptureScreenInput(displayID: displayId) + screenInput?.cropRect = box.insetBy(dx: 1, dy: 1) + + if self.canAddInput(screenInput!) { + self.addInput(screenInput!) + } + + videoOutput.setSampleBufferDelegate(self, queue: Dispatch.DispatchQueue(label: "sample buffer delegate", attributes: [])) + + if self.canAddOutput(videoOutput) { + self.addOutput(videoOutput) + } + + if self.canAddOutput(movieFileOutput) { + self.addOutput(movieFileOutput) + } + } + + func startRecording() { + receivedFrames = false + self.startRunning() + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + if !self.receivedFrames { + NotificationCenter.default.post(name: .failedToStart, object: nil, userInfo: nil) + } + } + } + + func startRecording(to url: URL) { + self.startRecording() + movieFileOutput.startRecording(to: url, recordingDelegate: self) + } + + // MARK: - AVCaptureVideoDataOutputSampleBufferDelegate Implementation + + func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { + receivedFrames = true + + guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + NotificationCenter.default.post(name: .receivedFrame, object: nil, userInfo: ["frame": imageBuffer]) + } + + // MARK: - AVCaptureFileOutputRecordingDelegate Implementation + + func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {} +} diff --git a/Captura/Data/CaptureState.swift b/Captura/Domain/CaptureState.swift similarity index 88% rename from Captura/Data/CaptureState.swift rename to Captura/Domain/CaptureState.swift index be354f6..0761b88 100644 --- a/Captura/Data/CaptureState.swift +++ b/Captura/Domain/CaptureState.swift @@ -4,4 +4,5 @@ enum CaptureState { case recording case uploading case uploaded + case error } diff --git a/Captura/Item.swift b/Captura/Item.swift deleted file mode 100644 index 22587d8..0000000 --- a/Captura/Item.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Item.swift -// Captura -// -// Created by Ruben Beltran del Rio on 7/24/23. -// - -import Foundation -import SwiftData - -@Model -final class Item { - var timestamp: Date - - init(timestamp: Date) { - self.timestamp = timestamp - } -} diff --git a/Captura/Presentation/Screens/PreferencesScreen.swift b/Captura/Presentation/Screens/PreferencesScreen.swift index f809502..a9c380b 100644 --- a/Captura/Presentation/Screens/PreferencesScreen.swift +++ b/Captura/Presentation/Screens/PreferencesScreen.swift @@ -2,9 +2,6 @@ import SwiftUI import SwiftData struct PreferencesScreen: View { - @Environment(\.modelContext) private var modelContext - @Query private var items: [Item] - var body: some View { TabView { OutputSettings().tabItem { @@ -13,11 +10,10 @@ struct PreferencesScreen: View { AdvancedSettings().tabItem { Label("Advanced", systemImage: "gear") }.padding(8.0) - }.padding(16.0) + }.padding(16.0).frame(minWidth: 300, minHeight: 250) } } #Preview { PreferencesScreen() - .modelContainer(for: Item.self, inMemory: true) } diff --git a/Captura/Presentation/Settings/AdvancedSettings.swift b/Captura/Presentation/Settings/AdvancedSettings.swift index 7ed78d9..e5b77ce 100644 --- a/Captura/Presentation/Settings/AdvancedSettings.swift +++ b/Captura/Presentation/Settings/AdvancedSettings.swift @@ -3,25 +3,45 @@ import SwiftUI struct AdvancedSettings: View { @AppStorage("backendUrl") var backendUrl: String = "" + @AppStorage("backendFormat") var outputFormats: OutputFormatSetting = .gifOnly @AppStorage("keepFiles") var keepFiles = true + var parsedBackendUrl: URL? { + URL(string: backendUrl) + } + var body: some View { Form { - VStack (alignment: .leading) { + VStack (alignment: .center) { LabeledContent("Backend URL") { - TextField("", text: $backendUrl) - }.font(.headline) - LabeledContent("Keep files after remote upload") { - Toggle("", isOn: $keepFiles) + TextField("", text: $backendUrl).font(.body) }.font(.headline) + Picker(selection: $outputFormats, label: Text("Backend Format").font(.headline)) { + Text("GIF") + .tag(OutputFormatSetting.gifOnly) + .padding(.horizontal, 4.0) + .padding(.vertical, 2.0) + Text("MP4") + .tag(OutputFormatSetting.mp4Only) + .padding(.horizontal, 4.0) + .padding(.vertical, 2.0) + } + .pickerStyle(.radioGroup) + .disabled(parsedBackendUrl == nil) + Toggle("Keep Local Files", isOn: $keepFiles) + .font(.headline) + .disabled(parsedBackendUrl == nil) + .padding(.vertical, 8.0) HStack { - Text("These settings can break things!") + Text("These settings can break things! Please make sure you understand how to use them before enabling.") + .lineLimit(3...10) Button { print("Not yet!") } label: { Image(systemName: "info.circle") }.buttonStyle(.borderless) } + Spacer() } } } diff --git a/Captura/Presentation/Settings/OutputSettings.swift b/Captura/Presentation/Settings/OutputSettings.swift index 3b8e01c..df12541 100644 --- a/Captura/Presentation/Settings/OutputSettings.swift +++ b/Captura/Presentation/Settings/OutputSettings.swift @@ -7,14 +7,14 @@ struct OutputSettings: View { var body: some View { Form { - VStack (alignment: .leading) { + VStack (alignment: .center) { LabeledContent("GIF Framerate") { Slider(value: $frameRate, in: 4...10, step: 1) { Text("\(Int(frameRate))").font(.body).frame(width: 24) } minimumValueLabel: { Text("4") } maximumValueLabel: { - Text("12") + Text("10") } }.font(.headline) Picker(selection: $outputFormats, label: Text("Output Formats").font(.headline)) { @@ -33,6 +33,7 @@ struct OutputSettings: View { .padding(.vertical, 2.0) }.pickerStyle(.radioGroup) } + Spacer() } } } diff --git a/Captura/Presentation/Windows/RecordingWindow.swift b/Captura/Presentation/Windows/RecordingWindow.swift index 2bb9928..b9f212c 100644 --- a/Captura/Presentation/Windows/RecordingWindow.swift +++ b/Captura/Presentation/Windows/RecordingWindow.swift @@ -7,6 +7,10 @@ class RecordingWindow: NSWindow { self.screen?.backingScaleFactor ?? 1.0 } + var recordingContentView: RecordingContentView { + self.contentView as! RecordingContentView + } + init(_ button: NSRect?) { let screens = NSScreen.screens diff --git a/README.md b/README.md new file mode 100644 index 0000000..9230a9d --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# Captura + +Bring your own backend screen recorder + +## Bring Your Own Backend (Protocol version 1.0) + +Captura allows you to define a URL that will be used to POST the recording. + +### The Request + +A `POST` will be made to the request, with the payload containing the binary +contents of the file. You can expect the following headers: + +* `User-Agent`: `Captura/1.0` +* `Content-Type`: `video/mp4` or `image/gif`, depending on the output format + selected. + +### Authentication / Authorization + +We will only do a POST request, so if you need any type of keys or user +identifiers, include them in the URL itself (eg. via path elements or +in the query parameters) + +### Expected Response + +If the upload is successful, the response *MUST* be a JSON object containing +a key called `url` with a value of type `string` corresponding to the URL +where the file is available. The status code *MUST* be 201 Created. + +Any response code other than 201 Created will be treated as an error. Captura +will not re-attempt an upload.