diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3ea37ec..62a5bd6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,21 +1,50 @@ name: CI on: - push - - pull_request + # - pull_request jobs: test: name: Node.js ${{ matrix.node-version }} - runs-on: macos-14 + runs-on: macos-15 strategy: fail-fast: false matrix: node-version: - 20 - - 18 + # - 18 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - run: npm install + - run: npm run build + - run: npm test + - name: Upload build + uses: actions/upload-artifact@v4 + with: + name: binding-artifact-${{ matrix.node-version }} + path: build + + test-intel: + needs: [test] + name: Node.js ${{ matrix.node-version }} (Intel) + runs-on: macos-13 + strategy: + fail-fast: false + matrix: + node-version: + - 20 + # - 18 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - name: Download build + uses: actions/download-artifact@v4 + with: + name: binding-artifact-${{ matrix.node-version }} + path: build - run: npm test diff --git a/.gitignore b/.gitignore index 7c83b10..ac6122a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ yarn.lock xcuserdata /Packages /*.xcodeproj -/aperture +/build recording.mp4 + + +# SwiftLint Remote Config Cache +.swiftlint/RemoteConfigCache diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..8f8d683 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,6 @@ +parent_config: https://raw.githubusercontent.com/sindresorhus/swiftlint-config/main/.swiftlint.yml +deployment_target: + macOS_deployment_target: '13' +excluded: + - .build + - node_modules diff --git a/Package.resolved b/Package.resolved index db40f48..6d8b241 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,17 +5,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wulkano/Aperture", "state" : { - "revision" : "ddfb0fc1b3c789339dd5fd9296ba8076d292611c", - "version" : "2.0.1" + "revision" : "7591bb540c844fe6c47edeac34b17c25e92a717f", + "version" : "3.0.0" } }, { - "identity" : "swift-argument-parser", + "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", + "location" : "https://github.com/swiftlang/swift-syntax.git", "state" : { - "revision" : "46989693916f56d1186bd59ac15124caef896560", - "version" : "1.3.1" + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" } } ], diff --git a/Package.swift b/Package.swift index 8a9be6f..c10d9e2 100644 --- a/Package.swift +++ b/Package.swift @@ -2,28 +2,28 @@ import PackageDescription let package = Package( - name: "ApertureCLI", + name: "aperture", platforms: [ - .macOS(.v10_13) + .macOS(.v13) ], products: [ - .executable( + .library( name: "aperture", - targets: [ - "ApertureCLI" - ] + type: .dynamic, + targets: ["ApertureNode"] ) ], dependencies: [ - .package(url: "https://github.com/wulkano/Aperture", from: "2.0.1"), - .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1") + .package(url: "https://github.com/wulkano/Aperture", from: "3.0.0"), + .package(path: "node_modules/node-swift") ], targets: [ - .executableTarget( - name: "ApertureCLI", + .target( + name: "ApertureNode", dependencies: [ "Aperture", - .product(name: "ArgumentParser", package: "swift-argument-parser") + .product(name: "NodeAPI", package: "node-swift"), + .product(name: "NodeModuleSupport", package: "node-swift") ] ) ] diff --git a/Sources/ApertureCLI/ApertureCLI.swift b/Sources/ApertureCLI/ApertureCLI.swift deleted file mode 100644 index 94fb4d7..0000000 --- a/Sources/ApertureCLI/ApertureCLI.swift +++ /dev/null @@ -1,172 +0,0 @@ -import Foundation -import Aperture -import ArgumentParser - -enum OutEvent: String, CaseIterable, ExpressibleByArgument { - case onStart - case onFileReady - case onPause - case onResume - case onFinish -} - -enum InEvent: String, CaseIterable, ExpressibleByArgument { - case pause - case resume - case isPaused - case onPause -} - -extension CaseIterable { - static var asCommaSeparatedList: String { - allCases.map { "\($0)" }.joined(separator: ", ") - } -} - -@main -struct ApertureCLI: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "aperture", - subcommands: [ - List.self, - Record.self, - Events.self - ] - ) -} - -extension ApertureCLI { - struct List: ParsableCommand { - static let configuration = CommandConfiguration( - subcommands: [ - Screens.self, - AudioDevices.self - ] - ) - } - - struct Record: ParsableCommand { - static let configuration = CommandConfiguration(abstract: "Start a recording with the given options.") - - @Option(name: .shortAndLong, help: "The ID to use for this process") - var processId = "main" - - @Argument(help: "Stringified JSON object with options passed to Aperture") - var options: String - - mutating func run() throws { - try record(options, processId: processId) - } - } - - struct Events: ParsableCommand { - static let configuration = CommandConfiguration( - subcommands: [ - Send.self, - Listen.self, - ListenAll.self - ] - ) - } -} - -extension ApertureCLI.List { - struct Screens: ParsableCommand { - static let configuration = CommandConfiguration(abstract: "List available screens.") - - mutating func run() throws { - // Uses stderr because of unrelated stuff being outputted on stdout. - print(try toJson(Aperture.Devices.screen().map { ["id": $0.id, "name": $0.name] }), to: .standardError) - } - } - - struct AudioDevices: ParsableCommand { - static let configuration = CommandConfiguration(abstract: "List available audio devices.") - - mutating func run() throws { - // Uses stderr because of unrelated stuff being outputted on stdout. - print(try toJson(Aperture.Devices.audio().map { ["id": $0.id, "name": $0.name] }), to: .standardError) - } - } -} - -extension ApertureCLI.Events { - struct Send: ParsableCommand { - static let configuration = CommandConfiguration(abstract: "Send an event to the given process.") - - @Flag(inversion: .prefixedNo, help: "Wait for event to be received") - var wait = true - - @Option(name: .shortAndLong, help: "The ID of the target process") - var processId = "main" - - @Argument(help: "Name of the event to send. Can be one of:\n\(InEvent.asCommaSeparatedList)") - var event: InEvent - - @Argument(help: "Data to pass to the event") - var data: String? - - mutating func run() { - ApertureEvents.sendEvent(processId: processId, event: event.rawValue, data: data) { notification in - if let data = notification.data { - print(data) - } - - Foundation.exit(0) - } - - if wait { - RunLoop.main.run() - } - } - } - - struct Listen: ParsableCommand { - static let configuration = CommandConfiguration(abstract: "Listen to an outcoming event for the given process.") - - @Flag(help: "Exit after receiving the event once") - var exit = false - - @Option(name: .shortAndLong, help: "The ID of the target process") - var processId = "main" - - @Argument(help: "Name of the event to listen for. Can be one of:\n\(OutEvent.asCommaSeparatedList)") - var event: OutEvent - - func run() { - _ = ApertureEvents.answerEvent(processId: processId, event: event.rawValue) { notification in - if let data = notification.data { - print(data) - } - - if exit { - notification.answer() - Foundation.exit(0) - } - } - - RunLoop.main.run() - } - } - - struct ListenAll: ParsableCommand { - static let configuration = CommandConfiguration(abstract: "Listen to all outcoming events for the given process.") - - @Option(name: .shortAndLong, help: "The ID of the target process") - var processId = "main" - - func run() { - for event in OutEvent.allCases { - _ = ApertureEvents.answerEvent(processId: processId, event: event.rawValue) { notification in - if let data = notification.data { - print("\(event) \(data)") - } else { - print(event) - } - } - } - - RunLoop.main.run() - } - } -} diff --git a/Sources/ApertureCLI/Utilities.swift b/Sources/ApertureCLI/Utilities.swift deleted file mode 100644 index 4033527..0000000 --- a/Sources/ApertureCLI/Utilities.swift +++ /dev/null @@ -1,262 +0,0 @@ -import Foundation - -// MARK: - SignalHandler -struct SignalHandler { - typealias CSignalHandler = @convention(c) (Int32) -> Void - typealias SignalHandler = (Signal) -> Void - - private static var handlers = [Signal: [SignalHandler]]() - - private static var cHandler: CSignalHandler = { rawSignal in - let signal = Signal(rawValue: rawSignal) - - guard let signalHandlers = handlers[signal] else { - return - } - - for handler in signalHandlers { - handler(signal) - } - } - - /** - Handle some signals - */ - static func handle(signals: [Signal], handler: @escaping SignalHandler) { - for signal in signals { - // Since Swift has no way of running code on "struct creation", we need to initialize here… - if handlers[signal] == nil { - handlers[signal] = [] - } - - handlers[signal]?.append(handler) - - var signalAction = sigaction( - __sigaction_u: unsafeBitCast(cHandler, to: __sigaction_u.self), - sa_mask: 0, - sa_flags: 0 - ) - - _ = withUnsafePointer(to: &signalAction) { pointer in - sigaction(signal.rawValue, pointer, nil) - } - } - } - - /** - Raise a signal. - */ - static func raise(signal: Signal) { - _ = Darwin.raise(signal.rawValue) - } - - /** - Ignore a signal. - */ - static func ignore(signal: Signal) { - _ = Darwin.signal(signal.rawValue, SIG_IGN) - } - - /** - Restore default signal handling. - */ - static func restore(signal: Signal) { - _ = Darwin.signal(signal.rawValue, SIG_DFL) - } -} - -extension SignalHandler { - struct Signal: Hashable { - static let hangup = Signal(rawValue: SIGHUP) - static let interrupt = Signal(rawValue: SIGINT) - static let quit = Signal(rawValue: SIGQUIT) - static let abort = Signal(rawValue: SIGABRT) - static let kill = Signal(rawValue: SIGKILL) - static let alarm = Signal(rawValue: SIGALRM) - static let termination = Signal(rawValue: SIGTERM) - static let userDefined1 = Signal(rawValue: SIGUSR1) - static let userDefined2 = Signal(rawValue: SIGUSR2) - - /** - Signals that cause the process to exit. - */ - static let exitSignals = [ - hangup, - interrupt, - quit, - abort, - alarm, - termination - ] - - let rawValue: Int32 - - init(rawValue: Int32) { - self.rawValue = rawValue - } - } -} - -extension [SignalHandler.Signal] { - static let exitSignals = SignalHandler.Signal.exitSignals -} -// MARK: - - - -// MARK: - CLI utils -extension FileHandle: TextOutputStream { - public func write(_ string: String) { - write(string.data(using: .utf8)!) - } -} - -enum CLI { - static var standardInput = FileHandle.standardInput - static var standardOutput = FileHandle.standardOutput - static var standardError = FileHandle.standardError - - static let arguments = Array(CommandLine.arguments.dropFirst(1)) -} - -extension CLI { - private static let once = Once() - - /** - Called when the process exits, either normally or forced (through signals). - - When this is set, it's up to you to exit the process. - */ - static var onExit: (() -> Void)? { - - didSet { - guard let exitHandler = onExit else { - return - } - - let handler = { - once.run(exitHandler) - } - - atexit_b { - handler() - } - - SignalHandler.handle(signals: .exitSignals) { _ in - handler() - } - } - } - - /** - Called when the process is being forced (through signals) to exit. - - When this is set, it's up to you to exit the process. - */ - static var onForcedExit: ((SignalHandler.Signal) -> Void)? { - didSet { - guard let exitHandler = onForcedExit else { - return - } - - SignalHandler.handle(signals: .exitSignals, handler: exitHandler) - } - } -} - -enum PrintOutputTarget { - case standardOutput - case standardError -} - -/** -Make `print()` accept an array of items. - -Since Swift doesn't support spreading... -*/ -private func print( - _ items: [Any], - separator: String = " ", - terminator: String = "\n", - to output: inout Target -) where Target: TextOutputStream { - let item = items.map { "\($0)" }.joined(separator: separator) - Swift.print(item, terminator: terminator, to: &output) -} - -func print( - _ items: Any..., - separator: String = " ", - terminator: String = "\n", - to output: PrintOutputTarget = .standardOutput -) { - switch output { - case .standardOutput: - print(items, separator: separator, terminator: terminator) - case .standardError: - print(items, separator: separator, terminator: terminator, to: &CLI.standardError) - } -} -// MARK: - - - -// MARK: - Misc -func synchronized(lock: AnyObject, closure: () throws -> T) rethrows -> T { - objc_sync_enter(lock) - defer { - objc_sync_exit(lock) - } - - return try closure() -} - -final class Once { - private var hasRun = false - - /** - Executes the given closure only once (thread-safe) - - ``` - final class Foo { - private let once = Once() - - func bar() { - once.run { - print("Called only once") - } - } - } - - let foo = Foo() - foo.bar() - foo.bar() - ``` - */ - func run(_ closure: () -> Void) { - synchronized(lock: self) { - guard !hasRun else { - return - } - - hasRun = true - closure() - } - } -} - -extension Data { - func jsonDecoded() throws -> T { - try JSONDecoder().decode(T.self, from: self) - } -} - -extension String { - func jsonDecoded() throws -> T { - try data(using: .utf8)!.jsonDecoded() - } -} - -func toJson(_ data: T) throws -> String { - let json = try JSONSerialization.data(withJSONObject: data) - return String(data: json, encoding: .utf8)! -} -// MARK: - diff --git a/Sources/ApertureCLI/notifications.swift b/Sources/ApertureCLI/notifications.swift deleted file mode 100644 index d26769e..0000000 --- a/Sources/ApertureCLI/notifications.swift +++ /dev/null @@ -1,124 +0,0 @@ -import Foundation - -final class ApertureNotification { - static func notificationName(forEvent event: String, processId: String) -> String { - "aperture.\(processId).\(event)" - } - - private var notification: Notification - var isAnswered = false - - init(_ notification: Notification) { - self.notification = notification - } - - func getField(_ name: String) -> T? { - notification.userInfo?[name] as? T - } - - var data: String? { getField("data") } - - func answer(_ data: Any? = nil) { - isAnswered = true - - guard - let responseIdentifier: String = getField("responseIdentifier") - else { - return - } - - var payload = [AnyHashable: Any]() - - if let payloadData = data { - payload["data"] = "\(payloadData)" - } - - DistributedNotificationCenter.default().postNotificationName( - .init(responseIdentifier), - object: nil, - userInfo: payload, - deliverImmediately: true - ) - } -} - -enum ApertureEvents { - static func answerEvent( - processId: String, - event: String, - using handler: @escaping (ApertureNotification) -> Void - ) -> NSObjectProtocol { - DistributedNotificationCenter.default().addObserver( - forName: .init(ApertureNotification.notificationName(forEvent: event, processId: processId)), - object: nil, - queue: nil - ) { notification in - let apertureNotification = ApertureNotification(notification) - handler(apertureNotification) - - if !apertureNotification.isAnswered { - apertureNotification.answer() - } - } - } - - static func sendEvent( - processId: String, - event: String, - data: Any?, - using callback: @escaping (ApertureNotification) -> Void - ) { - let notificationName = ApertureNotification.notificationName(forEvent: event, processId: processId) - let responseIdentifier = "\(notificationName).response.\(UUID().uuidString)" - - var payload: [AnyHashable: Any] = ["responseIdentifier": responseIdentifier] - - if let payloadData = data { - payload["data"] = "\(payloadData)" - } - - var observer: AnyObject? - observer = DistributedNotificationCenter.default().addObserver( - forName: .init(responseIdentifier), - object: nil, - queue: nil - ) { notification in - DistributedNotificationCenter.default().removeObserver(observer!) - callback(ApertureNotification(notification)) - } - - DistributedNotificationCenter.default().postNotificationName( - .init( - ApertureNotification.notificationName(forEvent: event, processId: processId) - ), - object: nil, - userInfo: payload, - deliverImmediately: true - ) - } - - static func sendEvent( - processId: String, - event: String, - using callback: @escaping (ApertureNotification) -> Void - ) { - sendEvent( - processId: processId, - event: event, - data: nil, - using: callback - ) - } - - static func sendEvent( - processId: String, - event: String, - data: Any? = nil - ) { - sendEvent( - processId: processId, - event: event, - data: data - ) { _ in } - } -} diff --git a/Sources/ApertureCLI/record.swift b/Sources/ApertureCLI/record.swift deleted file mode 100644 index a815614..0000000 --- a/Sources/ApertureCLI/record.swift +++ /dev/null @@ -1,90 +0,0 @@ -import AVFoundation -import Aperture - -struct Options: Decodable { - let destination: URL - let framesPerSecond: Int - let cropRect: CGRect? - let showCursor: Bool - let highlightClicks: Bool - let screenId: CGDirectDisplayID - let audioDeviceId: String? - let videoCodec: String? -} - -func record(_ optionsString: String, processId: String) throws { - setbuf(__stdoutp, nil) - let options: Options = try optionsString.jsonDecoded() - var observers = [Any]() - - let recorder = try Aperture( - destination: options.destination, - framesPerSecond: options.framesPerSecond, - cropRect: options.cropRect, - showCursor: options.showCursor, - highlightClicks: options.highlightClicks, - screenId: options.screenId == 0 ? .main : options.screenId, - audioDevice: options.audioDeviceId != nil ? AVCaptureDevice(uniqueID: options.audioDeviceId!) : nil, - videoCodec: options.videoCodec != nil ? AVVideoCodecType(rawValue: options.videoCodec!) : nil - ) - - recorder.onStart = { - ApertureEvents.sendEvent(processId: processId, event: OutEvent.onFileReady.rawValue) - } - - recorder.onPause = { - ApertureEvents.sendEvent(processId: processId, event: OutEvent.onPause.rawValue) - } - - recorder.onResume = { - ApertureEvents.sendEvent(processId: processId, event: OutEvent.onResume.rawValue) - } - - recorder.onFinish = { - switch $0 { - case .success(_): - // TODO: Handle warning on the JS side. - break - case .failure(let error): - print(error, to: .standardError) - exit(1) - } - - ApertureEvents.sendEvent(processId: processId, event: OutEvent.onFinish.rawValue) - - for observer in observers { - DistributedNotificationCenter.default().removeObserver(observer) - } - - exit(0) - } - - CLI.onExit = { - recorder.stop() - // Do not call `exit()` here as the video is not always done - // saving at this point and will be corrupted randomly - } - - observers.append( - ApertureEvents.answerEvent(processId: processId, event: InEvent.pause.rawValue) { _ in - recorder.pause() - } - ) - - observers.append( - ApertureEvents.answerEvent(processId: processId, event: InEvent.resume.rawValue) { _ in - recorder.resume() - } - ) - - observers.append( - ApertureEvents.answerEvent(processId: processId, event: InEvent.isPaused.rawValue) { notification in - notification.answer(recorder.isPaused) - } - ) - - recorder.start() - ApertureEvents.sendEvent(processId: processId, event: OutEvent.onStart.rawValue) - - RunLoop.main.run() -} diff --git a/Sources/ApertureNode/ApertureNode.swift b/Sources/ApertureNode/ApertureNode.swift new file mode 100644 index 0000000..fc0c12c --- /dev/null +++ b/Sources/ApertureNode/ApertureNode.swift @@ -0,0 +1,308 @@ +import NodeAPI +import Aperture +import Foundation +import AVFoundation + +@NodeClass final class Recorder { + @NodeActor + private var recorder: Aperture.Recorder + + private let nodeQueue: NodeAsyncQueue + + @NodeActor + @NodeConstructor + init () throws { + self.recorder = Aperture.Recorder() + self.nodeQueue = try NodeAsyncQueue(label: "Node Queue") + } + + @NodeActor + @NodeMethod + func start(_ targetString: NodeString, _ options: NodeObject) async throws { + let target: Aperture.Target + + switch try targetString.string() { + case "screen": + target = .screen + case "window": + target = .window + case "audioOnly": + target = .audioOnly + case "externalDevice": + target = .externalDevice + default: + throw try NodeError(code: nil, message: "Invalid value provided for target. screen, window, audioOnly or externalDevice expected.") + } + + try await self.recorder.start(target: target, options: options.asOptions()) + } + + @NodeActor + @NodeMethod + func stop() async throws { + try await self.recorder.stop() + } + + @NodeActor + @NodeMethod + func pause() throws { + try self.recorder.pause() + } + + @NodeActor + @NodeMethod + func resume() async throws { + try await self.recorder.resume() + } + + @NodeActor + @NodeMethod + func isPaused() async -> Bool { + self.recorder.isPaused + } + + @NodeActor private var _onFinish: NodeFunction? + @NodeActor @NodeProperty var onFinish: NodeFunction? { + get { + _onFinish + } + set { + _onFinish = newValue + + if let newValue { + self.recorder.onFinish = { + try? self.nodeQueue.run { + _ = try? newValue.call([]) + } + } + } else { + self.recorder.onFinish = nil + } + } + } + + @NodeActor private var _onStart: NodeFunction? + @NodeActor @NodeProperty var onStart: NodeFunction? { + get { + _onStart + } + set { + _onStart = newValue + + if let newValue { + self.recorder.onStart = { + try? self.nodeQueue.run { + _ = try? newValue.call([]) + } + } + } else { + self.recorder.onStart = nil + } + } + } + + @NodeActor private var _onPause: NodeFunction? + @NodeActor @NodeProperty var onPause: NodeFunction? { + get { + _onPause + } + set { + _onPause = newValue + + if let newValue { + self.recorder.onPause = { + try? self.nodeQueue.run { + _ = try? newValue.call([]) + } + } + } else { + self.recorder.onPause = nil + } + } + } + + @NodeActor private var _onResume: NodeFunction? + @NodeActor @NodeProperty var onResume: NodeFunction? { + get { + _onResume + } + set { + _onResume = newValue + + if let newValue { + self.recorder.onResume = { + try? self.nodeQueue.run { + _ = try? newValue.call([]) + } + } + } else { + self.recorder.onResume = nil + } + } + } + + @NodeActor private var _onError: NodeFunction? + @NodeActor @NodeProperty var onError: NodeFunction? { + get { + _onError + } + set { + _onError = newValue + + if let newValue { + self.recorder.onError = { error in + try? self.nodeQueue.run { + _ = try? newValue.call([ + try NodeError(code: nil, message: error.localizedDescription) + ]) + } + } + } else { + self.recorder.onError = nil + } + } + } +} + +#NodeModule(exports: [ + "getScreens": try NodeFunction { () async throws in + let screens = try await Aperture.Devices.screen() + return screens as [any NodeValueConvertible] + }, + "getWindows": try NodeFunction { (excludeDesktopWindows: Bool, onScreenWindowsOnly: Bool) async throws in + let windows = try await Aperture.Devices.window(excludeDesktopWindows: excludeDesktopWindows, onScreenWindowsOnly: onScreenWindowsOnly) + return windows as [any NodeValueConvertible] + }, + "getAudioDevices": try NodeFunction { () async in + let audioDevices = Aperture.Devices.audio() + return audioDevices as [any NodeValueConvertible] + }, + "getIOSDevices": try NodeFunction { () async in + let iosDevices = Aperture.Devices.iOS() + return iosDevices as [any NodeValueConvertible] + }, + "Recorder": Recorder.deferredConstructor +]) + +extension NodeObject { + func getAs(_ name: String, type: T.Type) throws -> T? { + if try self.hasOwnProperty(name) { + guard let value = try self[name].as(T.self) else { + throw try NodeError(code: nil, message: "Invalid value provided for \(name). \(type) expected.") + } + + return value + } + return nil + } + + func getAsRequired(_ name: String, type: T.Type, errorMessage: String? = nil) throws -> T { + guard let value = try getAs(name, type: type.self) else { + throw try NodeError(code: nil, message: "\(name) is required") + } + + return value + } +} + +extension NodeObject { + func asCGRect() throws -> CGRect { + CGRect( + origin: CGPoint( + x: try getAsRequired("x", type: Int.self), + y: try getAsRequired("y", type: Int.self) + ), + size: CGSize( + width: try getAsRequired("width", type: Int.self), + height: try getAsRequired("height", type: Int.self) + ) + ) + } + + func asOptions() throws -> Aperture.RecordingOptions { + let destinationPath = try self.getAsRequired("destination", type: String.self) + let destination = URL(fileURLWithPath: destinationPath) + + let videoCodecString = try getAs("videoCodec", type: String.self) + let videoCodec: Aperture.VideoCodec? + + if let videoCodecString { + do { + videoCodec = try .fromRawValue(videoCodecString) + } catch { + throw try NodeError(code: nil, message: "Invalid value provided for videoCodec. h264, hevc, proRes422 or proRes4444 expected.") + } + } else { + videoCodec = nil + } + + return Aperture.RecordingOptions( + destination: destination, + targetID: try getAs("targetID", type: String.self), + framesPerSecond: try getAs("framesPerSecond", type: Int.self) ?? 60, + cropRect: try getAs("cropRect", type: NodeObject.self)?.asCGRect(), + showCursor: try getAs("showCursor", type: Bool.self) ?? true, + highlightClicks: try getAs("highlightClicks", type: Bool.self) ?? false, + videoCodec: videoCodec ?? .h264, + losslessAudio: try getAs("losslessAudio", type: Bool.self) ?? false, + recordSystemAudio: try getAs("recordSystemAudio", type: Bool.self) ?? false, + microphoneDeviceID: try getAs("microphoneDeviceID", type: String.self) + ) + } +} + +extension CGRect: @retroactive NodeValueConvertible { + public func nodeValue() throws -> any NodeValue { + try NodeObject([ + "x": Int(self.origin.x), + "y": Int(self.origin.y), + "width": Int(self.size.width), + "height": Int(self.size.height) + ]) + } +} + +extension Aperture.Devices.Screen: @retroactive NodeValueConvertible { + public func nodeValue() throws -> any NodeValue { + try NodeObject([ + "id": String(self.id), + "name": self.name, + "width": self.width, + "height": self.height, + "frame": self.frame.nodeValue() + ]) + } +} + +extension Aperture.Devices.Window: @retroactive NodeValueConvertible { + public func nodeValue() throws -> any NodeValue { + try NodeObject([ + "id": String(self.id), + "title": self.title, + "frame": self.frame.nodeValue(), + "appName": self.appName, + "appBundleIdentifier": self.appBundleIdentifier, + "isActive": self.isActive, + "isOnScreen": self.isOnScreen, + "layer": self.layer + ]) + } +} + +extension Aperture.Devices.Audio: @retroactive NodeValueConvertible { + public func nodeValue() throws -> any NodeValue { + try NodeObject([ + "id": String(self.id), + "name": self.name + ]) + } +} + +extension Aperture.Devices.IOS: @retroactive NodeValueConvertible { + public func nodeValue() throws -> any NodeValue { + try NodeObject([ + "id": String(self.id), + "name": self.name + ]) + } +} diff --git a/example.js b/example.js index 8e62318..4726912 100644 --- a/example.js +++ b/example.js @@ -2,18 +2,20 @@ import fs from 'node:fs'; import timers from 'node:timers/promises'; import { recorder, - screens, - audioDevices, + screens as getScreens, + audioDevices as getAudioDevices, videoCodecs, } from './index.js'; async function main() { - console.log('Screens:', await screens()); - console.log('Audio devices:', await audioDevices()); + const screens = await getScreens(); + console.log('Screens:', screens); + const audioDevices = await getAudioDevices(); + console.log('Audio devices:', audioDevices); console.log('Video codecs:', videoCodecs); console.log('Preparing to record for 5 seconds'); - await recorder.startRecording(); + await recorder.startRecordingScreen({screenId: screens[0].id, audioDeviceId: audioDevices[0].id}); console.log('Recording started'); await recorder.isFileReady; console.log('File is ready'); diff --git a/index.d.ts b/index.d.ts index 51162c6..117f158 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,6 +1,29 @@ +import {type RequireAtLeastOne} from 'type-fest'; + +export type Frame = { + x: number; + y: number; + width: number; + height: number; +}; + export type Screen = { - id: number; + id: string; name: string; + width: number; + height: number; + frame: Frame; +}; + +export type Window = { + id: string; + title?: string; + appName?: string; + appBundleIdentifier?: string; + isActive: boolean; + isOnScreen: boolean; + layer: number; + frame: Frame; }; export type AudioDevice = { @@ -8,23 +31,37 @@ export type AudioDevice = { name: string; }; +export type ExternalDevice = { + id: string; + name: string; +}; + export type VideoCodec = 'h264' | 'hevc' | 'proRes422' | 'proRes4444'; -export type RecordingOptions = { +export type AudioRecordingOptions = { /** - Number of frames per seconds. + Audio device to include in the screen recording. + + Should be one of the `id`'s from `audioDevices()`. */ - readonly fps?: number; + readonly audioDeviceId?: string; + + /** + Record audio in a lossless format. + */ + readonly losslessAudio?: boolean; /** - Record only an area of the screen. + Record system audio. + */ + readonly systemAudio?: boolean; +}; + +export type VideoRecordingOptions = AudioRecordingOptions & { + /** + Number of frames per seconds. */ - readonly cropArea?: { - x: number; - y: number; - width: number; - height: number; - }; + readonly fps?: number; /** Show the cursor in the screen recording. @@ -39,34 +76,74 @@ export type RecordingOptions = { readonly highlightClicks?: boolean; /** - Screen to record. + Video codec to use. + + A computer with Intel 6th generation processor or newer is strongly recommended for the `hevc` codec, as otherwise it will use software encoding, which only produces 3 FPS fullscreen recording. - Defaults to primary screen. + The `proRes422` and `proRes4444` codecs are uncompressed data. They will create huge files. */ - readonly screenId?: number; + readonly videoCodec?: Codec; /** - Audio device to include in the screen recording. + The extension of the output file. - Should be one of the `id`'s from `audioDevices()`. + The `proRes422` and `proRes4444` codecs only support the `mov` extension. */ - readonly audioDeviceId?: string; + readonly extension?: Codec extends 'proRes422' | 'proRes4444' ? 'mov' : ('mp4' | 'mov' | 'm4v'); +}; +export declare class Recorder { /** - Video codec to use. + Returns a `Promise` that fullfills when the recording starts or rejects if the recording didn't start after 5 seconds. + */ + startRecordingScreen: ( + options: VideoRecordingOptions & { + /** + The id of the screen to record. + + Should be one of the `id`'s from `screens()`. + */ + readonly screenId: string; + + /** + Record only an area of the screen. + */ + readonly cropArea?: Frame; + } + ) => Promise; - A computer with Intel 6th generation processor or newer is strongly recommended for the `hevc` codec, as otherwise it will use software encoding, which only produces 3 FPS fullscreen recording. + /** + Returns a `Promise` that fullfills when the recording starts or rejects if the recording didn't start after 5 seconds. + */ + startRecordingWindow: ( + options: VideoRecordingOptions & { + /** + The id of the screen to record. - The `proRes422` and `proRes4444` codecs are uncompressed data. They will create huge files. + Should be one of the `id`'s from `windows()`. + */ + readonly windowId: string; + } + ) => Promise; + + /** + Returns a `Promise` that fullfills when the recording starts or rejects if the recording didn't start after 5 seconds. */ - readonly videoCodec?: VideoCodec; -}; + startRecordingExternalDevice: ( + options: Omit, 'showCursor' | 'highlightClicks'> & { + /** + The id of the screen to record. + + Should be one of the `id`'s from `extranlDevices()`. + */ + readonly deviceId: string; + } + ) => Promise; -export type Recorder = { /** Returns a `Promise` that fullfills when the recording starts or rejects if the recording didn't start after 5 seconds. */ - startRecording: (options?: RecordingOptions) => Promise; + startRecordingAudio: (options: RequireAtLeastOne) => Promise; /** `Promise` that fullfills with the path to the screen recording file when it's ready. This will never reject. @@ -100,7 +177,7 @@ export type Recorder = { Returns a `Promise` for the path to the screen recording file. */ stopRecording: () => Promise; -}; +} /** Get a list of available video codecs. @@ -129,13 +206,61 @@ The first screen is the primary screen. @example ``` [{ - id: 69732482, - name: 'Color LCD' + id: '69732482', + name: 'Color LCD', + width: 1280, + height: 800, + frame: { + x: 0, + y: 0, + width: 1280, + height: 800 + } }] ``` */ export function screens(): Promise; +export type WindowOptions = { + /** + Exclude desktop windows like Finder, Dock, and Desktop. + + @default true + */ + readonly excludeDesktopWindows?: boolean; + + /** + Only include windows that are on screen. + + @default true + */ + readonly onScreenOnly?: boolean; +}; + +/** +Get a list of windows. + +@example +``` +[{ + id: '69732482', + title: 'Unicorn', + appName: 'Safari', + appBundleIdentifier: 'com.apple.Safari', + isActive: true, + isOnScreen: true, + layer: 0, + frame: { + x: 0, + y: 0, + width: 1280, + height: 800 + } +}] +``` +*/ +export function windows(options?: WindowOptions): Promise; + /** Get a list of audio devices. @@ -149,4 +274,17 @@ Get a list of audio devices. */ export function audioDevices(): Promise; +/** +Get a list of external devices. + +@example +``` +[{ + id: '9eb08da55a14244bf8044bf0f75247d2cb9c364c', + name: 'iPad Pro' +}] +``` +*/ +export function externalDevices(): Promise; + export const recorder: Recorder; diff --git a/index.js b/index.js index 65bdda6..968f71c 100644 --- a/index.js +++ b/index.js @@ -1,221 +1,128 @@ -import os from 'node:os'; -import {debuglog} from 'node:util'; -import path from 'node:path'; -import url from 'node:url'; -import {execa} from 'execa'; -import {temporaryFile} from 'tempy'; +import {createRequire} from 'node:module'; import {assertMacOSVersionGreaterThanOrEqualTo} from 'macos-version'; -import fileUrl from 'file-url'; -import {fixPathForAsarUnpack} from 'electron-util/node'; -import delay from 'delay'; +import {normalizeOptions} from './utils.js'; -const log = debuglog('aperture'); -const getRandomId = () => Math.random().toString(36).slice(2, 15); +export {videoCodecs} from './utils.js'; -const dirname_ = path.dirname(url.fileURLToPath(import.meta.url)); -// Workaround for https://github.com/electron/electron/issues/9459 -const BINARY = path.join(fixPathForAsarUnpack(dirname_), 'aperture'); +const nativeModule = createRequire(import.meta.url)('./build/aperture.framework/Versions/A/aperture.node'); -const supportsHevcHardwareEncoding = (() => { - const cpuModel = os.cpus()[0].model; - - // All Apple silicon Macs support HEVC hardware encoding. - if (cpuModel.startsWith('Apple ')) { - // Source string example: `'Apple M1'` - return true; +export class Recorder { + constructor() { + assertMacOSVersionGreaterThanOrEqualTo('13'); } - // Get the Intel Core generation, the `4` in `Intel(R) Core(TM) i7-4850HQ CPU @ 2.30GHz` - // More info: https://www.intel.com/content/www/us/en/processors/processor-numbers.html - // Example strings: - // - `Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz` - // - `Intel(R) Core(TM) i7-4850HQ CPU @ 2.30GHz` - const result = /Intel.*Core.*i\d+-(\d)/.exec(cpuModel); - - // Intel Core generation 6 or higher supports HEVC hardware encoding - return result && Number.parseInt(result[1], 10) >= 6; -})(); + startRecordingScreen({ + screenId, + ...options + }) { + return this._startRecording('screen', { + ...options, + targetId: screenId, + }); + } -class Recorder { - constructor() { - assertMacOSVersionGreaterThanOrEqualTo('10.13'); + startRecordingWindow({ + windowId, + ...options + }) { + return this._startRecording('window', { + ...options, + targetId: windowId, + }); } - startRecording({ - fps = 30, - cropArea = undefined, - showCursor = true, - highlightClicks = false, - screenId = 0, - audioDeviceId = undefined, - videoCodec = 'h264', - } = {}) { - this.processId = getRandomId(); + startRecordingExternalDevice({ + deviceId, + ...options + }) { + return this._startRecording('externalDevice', { + ...options, + targetId: deviceId, + }); + } - return new Promise((resolve, reject) => { - if (this.recorder !== undefined) { - reject(new Error('Call `.stopRecording()` first')); - return; - } + startRecordingAudio({ + audioDeviceId, + losslessAudio, + systemAudio, + }) { + return this._startRecording('audioOnly', { + audioDeviceId, + losslessAudio, + systemAudio, + extension: 'm4a', + }); + } - this.tmpPath = temporaryFile({extension: 'mp4'}); + async _startRecording(targetType, options) { + if (this.recorder !== undefined) { + throw new Error('Call `.stopRecording()` first'); + } - if (highlightClicks === true) { - showCursor = true; - } + const {tmpPath, recorderOptions} = normalizeOptions(targetType, options); - if ( - typeof cropArea === 'object' - && (typeof cropArea.x !== 'number' - || typeof cropArea.y !== 'number' - || typeof cropArea.width !== 'number' - || typeof cropArea.height !== 'number') - ) { - reject(new Error('Invalid `cropArea` option object')); - return; - } + this.tmpPath = tmpPath; + this.recorder = new nativeModule.Recorder(); - const recorderOptions = { - destination: fileUrl(this.tmpPath), - framesPerSecond: fps, - showCursor, - highlightClicks, - screenId, - audioDeviceId, + this.isFileReady = new Promise(resolve => { + this.recorder.onStart = () => { + resolve(this.tmpPath); }; - - if (cropArea) { - recorderOptions.cropRect = [ - [cropArea.x, cropArea.y], - [cropArea.width, cropArea.height], - ]; - } - - if (videoCodec) { - const codecMap = new Map([ - ['h264', 'avc1'], - ['hevc', 'hvc1'], - ['proRes422', 'apcn'], - ['proRes4444', 'ap4h'], - ]); - - if (!supportsHevcHardwareEncoding) { - codecMap.delete('hevc'); - } - - if (!codecMap.has(videoCodec)) { - throw new Error(`Unsupported video codec specified: ${videoCodec}`); - } - - recorderOptions.videoCodec = codecMap.get(videoCodec); - } - - const timeout = setTimeout(() => { - // `.stopRecording()` was called already - if (this.recorder === undefined) { - return; - } - - const error = new Error('Could not start recording within 5 seconds'); - error.code = 'RECORDER_TIMEOUT'; - this.recorder.kill(); - delete this.recorder; - reject(error); - }, 5000); - - (async () => { - try { - await this.waitForEvent('onStart'); - clearTimeout(timeout); - setTimeout(resolve, 1000); - } catch (error) { - reject(error); - } - })(); - - this.isFileReady = (async () => { - await this.waitForEvent('onFileReady'); - return this.tmpPath; - })(); - - this.recorder = execa(BINARY, [ - 'record', - '--process-id', - this.processId, - JSON.stringify(recorderOptions), - ]); - - this.recorder.catch(error => { - clearTimeout(timeout); - delete this.recorder; - reject(error); - }); - - this.recorder.stdout.setEncoding('utf8'); - this.recorder.stdout.on('data', log); }); - } - async waitForEvent(name, parse) { - const {stdout} = await execa(BINARY, [ - 'events', - 'listen', - '--process-id', - this.processId, - '--exit', - name, - ]); + const finalOptions = { + destination: tmpPath, + framesPerSecond: recorderOptions.framesPerSecond, + showCursor: recorderOptions.showCursor, + highlightClicks: recorderOptions.highlightClicks, + losslessAudio: recorderOptions.losslessAudio, + recordSystemAudio: recorderOptions.recordSystemAudio, + }; + + if (recorderOptions.videoCodec) { + finalOptions.videoCodec = recorderOptions.videoCodec; + } - if (parse) { - return parse(stdout.trim()); + if (targetType === 'screen' && options.cropArea) { + finalOptions.cropRect = options.cropArea; } - } - async sendEvent(name, parse) { - const {stdout} = await execa(BINARY, [ - 'events', - 'send', - '--process-id', - this.processId, - name, - ]); + if (recorderOptions.targetId) { + finalOptions.targetID = recorderOptions.targetId; + } - if (parse) { - return parse(stdout.trim()); + if (recorderOptions.audioDeviceId) { + finalOptions.microphoneDeviceID = recorderOptions.audioDeviceId; } + + await this.recorder.start(targetType, finalOptions); } throwIfNotStarted() { if (this.recorder === undefined) { - throw new Error('Call `.startRecording()` first'); + throw new Error('Recording not started yet'); } } async pause() { this.throwIfNotStarted(); - await this.sendEvent('pause'); + this.recorder.pause(); } async resume() { this.throwIfNotStarted(); - - await this.sendEvent('resume'); - - // It takes about 1s after the promise resolves for the recording to actually start - await delay(1000); + this.recorder.resume(); } async isPaused() { this.throwIfNotStarted(); - - return this.sendEvent('isPaused', value => value === 'true'); + return this.recorder.isPaused(); } async stopRecording() { this.throwIfNotStarted(); + await this.recorder.stop(); - this.recorder.kill(); - await this.recorder; delete this.recorder; delete this.isFileReady; @@ -225,35 +132,13 @@ class Recorder { export const recorder = new Recorder(); -const removeWarnings = string => string.split('\n').filter(line => !line.includes('] WARNING:')).join('\n'); +export const screens = async () => nativeModule.getScreens(); -export const screens = async () => { - const {stderr} = await execa(BINARY, ['list', 'screens']); - - try { - return JSON.parse(removeWarnings(stderr)); - } catch (error) { - throw new Error(stderr, {cause: error}); - } -}; +export const windows = async ({ + excludeDesktopWindows = true, + onScreenOnly = true, +} = {}) => nativeModule.getWindows(excludeDesktopWindows, onScreenOnly); -export const audioDevices = async () => { - const {stderr} = await execa(BINARY, ['list', 'audio-devices']); +export const audioDevices = async () => nativeModule.getAudioDevices(); - try { - return JSON.parse(removeWarnings(stderr)); - } catch (error) { - throw new Error(stderr, {cause: error}); - } -}; - -export const videoCodecs = new Map([ - ['h264', 'H264'], - ['hevc', 'HEVC'], - ['proRes422', 'Apple ProRes 422'], - ['proRes4444', 'Apple ProRes 4444'], -]); - -if (!supportsHevcHardwareEncoding) { - videoCodecs.delete('hevc'); -} +export const externalDevices = async () => nativeModule.getIOSDevices(); diff --git a/index.test-d.ts b/index.test-d.ts index d199919..113e40e 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -16,7 +16,29 @@ expectType(await audioDevices()); expectType(await screens()); -expectError(recorder.startRecording({videoCodec: 'random'})); +expectError(recorder.startRecordingScreen({})); + +expectError(recorder.startRecordingScreen({screenId: '1', videoCodec: 'random'})); + +expectError(recorder.startRecordingScreen({screenId: '1', videoCodec: 'proRes422', extension: 'mp4'})); + +expectType>(recorder.startRecordingScreen({screenId: '1', videoCodec: 'proRes422', extension: 'mov'})); + +expectType>(recorder.startRecordingScreen({screenId: '1', extension: 'mp4'})); + +expectType>(recorder.startRecordingScreen({screenId: '1'})); + +expectError(recorder.startRecordingWindow({})); + +expectType>(recorder.startRecordingWindow({windowId: '1'})); + +expectError(recorder.startRecordingExternalDevice({})); + +expectType>(recorder.startRecordingExternalDevice({deviceId: '1'})); + +expectError(recorder.startRecordingAudio({losslessAudio: true})); + +expectType>(recorder.startRecordingAudio({systemAudio: true, audioDeviceId: '1'})); expectType(await recorder.isFileReady); diff --git a/package.json b/package.json index 44a80ba..8acc5d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aperture", - "version": "7.0.0", + "version": "8.0.0", "description": "Record the screen on macOS", "license": "MIT", "repository": "wulkano/aperture-node", @@ -13,29 +13,33 @@ "engines": { "node": ">=18" }, + "swift": { "builder": "xcode" }, "scripts": { "test": "xo && ava && tsd", - "build": "swift build --configuration=release --arch arm64 --arch x86_64 && mv .build/apple/Products/Release/aperture .", - "prepublish": "npm run build" + "build": "npm run build:build && npm run build:move", + "build:build": "node-swift build", + "build:move": "rm -rf build && mkdir build && mv ./.build/release/aperture.framework ./build/aperture.framework && mv ./.build/release/NodeAPI.framework ./build/NodeAPI.framework", + "prepack": "npm run build" }, "files": [ "index.js", - "aperture", - "index.d.ts" + "index.d.ts", + "utils.js", + "build" ], "dependencies": { - "delay": "^6.0.0", - "electron-util": "^0.18.1", - "execa": "^8.0.1", "file-url": "^4.0.0", "macos-version": "^6.0.0", "tempy": "^3.1.0" }, "devDependencies": { "ava": "^6.1.2", + "delay": "^6.0.0", "file-type": "^19.0.0", + "node-swift": "github:kabiroberai/node-swift#1.4.0", "read-chunk": "^4.0.3", "tsd": "^0.30.7", + "type-fest": "^4.26.1", "xo": "^0.58.0" } } diff --git a/readme.md b/readme.md index 8eebc13..432143e 100644 --- a/readme.md +++ b/readme.md @@ -8,15 +8,18 @@ npm install aperture ``` -*Requires macOS 10.13 or later.* +*Requires macOS 13 or later.* ## Usage ```js import {setTimeout} from 'node:timers/promises'; -import {recorder} from 'aperture'; +import {recorder, screens} from 'aperture'; + +const allScreens = await screens(); const options = { + screenId: allScreens[0].id, fps: 30, cropArea: { x: 100, @@ -26,7 +29,7 @@ const options = { }, }; -await recorder.startRecording(options); +await recorder.startRecordingScreen(options); await setTimeout(3000); @@ -38,7 +41,7 @@ See [`example.js`](example.js) if you want to quickly try it out. _(The example ## API -#### screens() -> `Promise` +#### screens() -> `Promise` Get a list of screens. The first screen is the primary screen. @@ -47,13 +50,62 @@ Example: ```js [ { - id: 69732482, + id: '69732482', name: 'Color LCD', + width: 1280, + height: 800, + frame: { + x: 0, + y: 0, + width: 1280, + height: 800 + } }, ]; ``` -#### audioDevices() -> `Promise` +#### windows(options: WindowOptions) -> `Promise` + +Get a list of windows + +##### WindowOptions.excludeDesktopWindows + +Type: `Boolean`\ +Default: `true` + +Exclude desktop windows like Finder, Dock, and Desktop. + +##### WindowOptions.onScreenOnly + +Type: `Boolean`\ +Default: `true` + +Only include windows that are on screen. + + +Example: + +```js +[ + { + id: '69732482', + title: 'Unicorn', + applicationName: 'Safari', + applicationBundleIdentifier: 'com.apple.Safari', + isActive: true, + isOnScreen: true, + layer: 0, + frame: { + x: 0, + y: 0, + width: 1280, + height: 800 + } + } +]; +``` + +#### audioDevices() -> `Promise` Get a list of audio devices. @@ -68,6 +120,21 @@ Example: ]; ``` +#### externalDevices() -> `Promise` + +Get a list of external devices. + +Example: + +```js +[ + { + id: '9eb08da55a14244bf8044bf0f75247d2cb9c364c', + name: 'iPad Pro' + }, +]; +``` + #### videoCodecs -> `Map` Get a list of available video codecs. The key is the `videoCodec` option name and the value is the codec name. It only returns `hevc` if your computer supports HEVC hardware encoding. @@ -83,97 +150,165 @@ Map { } ``` -#### recorder +#### Audio Recording Options -#### recorder.startRecording([options?](#options)) +##### audioDeviceId -Returns a `Promise` that fullfills when the recording starts or rejects if the recording didn't start after 5 seconds. +Type: `string`\ +Default: `undefined` -#### recorder.isFileReady +Audio device to include in the screen recording. Should be one of the `id`'s from `aperture.audioDevices()`. -`Promise` that fullfills with the path to the screen recording file when it's ready. This will never reject. +##### losslessAudio -Only available while a recording is happening, `undefined` otherwise. +Type: `boolean`\ +Default: `false` -Usually, this resolves around 1 second before the recording starts, but that's not guaranteed. +Record audio in a lossless format (ALAC). Uses lossy (AAC) otherwise. -#### recorder.pause() +##### systemAudio -Pauses the recording. To resume, call `recorder.resume()`. +Type: `boolean`\ +Default: `false` -Returns a `Promise` that fullfills when the recording has been paused. +Record system audio. -#### recorder.resume() +#### Video Recording Options -Resumes the recording if it's been paused. +##### fps -Returns a `Promise` that fullfills when the recording has been resumed. +Type: `number`\ +Default: `30` -#### recorder.isPaused() +Number of frames per seconds. -Returns a `Promise` that resolves with a boolean indicating whether or not the recording is currently paused. +##### showCursor -#### recorder.stopRecording() +Type: `boolean`\ +Default: `true` -Returns a `Promise` for the path to the screen recording file. +Show the cursor in the screen recording. -## Options +##### highlightClicks -Type: `object` +Type: `boolean`\ +Default: `false` -#### fps +Highlight cursor clicks in the screen recording. -Type: `number`\ -Default: `30` +Enabling this will also enable the `showCursor` option. -Number of frames per seconds. +##### videoCodec + +Type: `string`\ +Default: `'h264'`\ +Values: `'hevc' | 'h264' | 'proRes422' | 'proRes4444'` + +A computer with Intel 6th generation processor or newer is strongly recommended for the `hevc` codec, as otherwise it will use software encoding, which only produces 3 FPS fullscreen recording. + +The [`proRes422` and `proRes4444`](https://documentation.apple.com/en/finalcutpro/professionalformatsandworkflows/index.html#chapter=10%26section=2%26tasks=true) codecs are uncompressed data. They will create huge files. + +##### extension + +Type: `string`\ +Default: + +- `'m4a'` for [audio](#recorderstartrecordingaudiooptions) recordings +- `'mov'` for `proRes422` and `proRes4444` video codecs +- `'mp4'` otherwise + +Values: + +- `'m4a'` is the only valid option for [audio](#recorderstartrecordingaudiooptions) recordings +- `'mov'` is the only valid option for `proRes422` and `proRes4444` video codecs +- `'mp4' | 'm4v' | 'mov'` for all other video codecs + +The extension of the output file + +#### recorder + +#### recorder.startRecordingScreen(options) -#### cropArea +Returns a `Promise` that fullfills when the recording starts. + +Accepts all [video](#video-recording-options) and [audio](#audio-recording-options) options, along with + +##### screenId + +Type: `string` + +The identifier of the screen to record. + +Should be one of the `id`'s from `screens()`. + +##### cropArea Type: `object`\ Default: `undefined` Record only an area of the screen. Accepts an object with `x`, `y`, `width`, `height` properties. -#### showCursor +#### recorder.startRecordingWindow(options) -Type: `boolean`\ -Default: `true` +Returns a `Promise` that fullfills when the recording starts. -Show the cursor in the screen recording. +Accepts all [video](#video-recording-options) and [audio](#audio-recording-options) options, along with -#### highlightClicks +##### windowId -Type: `boolean`\ -Default: `false` +Type: `string` -Highlight cursor clicks in the screen recording. +The id of the screen to record. -Enabling this will also enable the `showCursor` option. +Should be one of the `id`'s from `windows()`. -#### screenId +#### recorder.startRecordingExternalDevice(options) -Type: `number`\ -Default: `aperture.screens()[0]` _(Primary screen)_ +Returns a `Promise` that fullfills when the recording starts. -Screen to record. +Accepts all [video](#video-recording-options) and [audio](#audio-recording-options) options, along with -#### audioDeviceId +##### deviceId -Type: `string`\ -Default: `undefined` +Type: `string` -Audio device to include in the screen recording. Should be one of the `id`'s from `aperture.audioDevices()`. +The id of the screen to record. -#### videoCodec +Should be one of the `id`'s from `externalDevices()`. -Type: `string`\ -Default: `'h264'`\ -Values: `'hevc' | 'h264' | 'proRes422' | 'proRes4444'` +#### recorder.startRecordingAudio(options) -A computer with Intel 6th generation processor or newer is strongly recommended for the `hevc` codec, as otherwise it will use software encoding, which only produces 3 FPS fullscreen recording. +Returns a `Promise` that fullfills when the recording starts. -The [`proRes422` and `proRes4444`](https://documentation.apple.com/en/finalcutpro/professionalformatsandworkflows/index.html#chapter=10%26section=2%26tasks=true) codecs are uncompressed data. They will create huge files. +Accepts all [audio](#audio-recording-options) options + +#### recorder.isFileReady + +`Promise` that fullfills with the path to the screen recording file when it's ready. This will never reject. + +Only available while a recording is happening, `undefined` otherwise. + +Usually, this resolves around 1 second before the recording starts, but that's not guaranteed. + +#### recorder.pause() + +Pauses the recording. To resume, call `recorder.resume()`. + +Returns a `Promise` that fullfills when the recording has been paused. + +#### recorder.resume() + +Resumes the recording if it's been paused. + +Returns a `Promise` that fullfills when the recording has been resumed. + +#### recorder.isPaused() + +Returns a `Promise` that resolves with a boolean indicating whether or not the recording is currently paused. + +#### recorder.stopRecording() + +Returns a `Promise` for the path to the screen recording file. ## Why diff --git a/test.js b/test.js index f060969..5f73107 100644 --- a/test.js +++ b/test.js @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import os from 'node:os'; import test from 'ava'; import delay from 'delay'; import {fileTypeFromBuffer} from 'file-type'; @@ -10,9 +11,10 @@ import { videoCodecs, } from './index.js'; +console.log(`Running on macOS ${os.arch()} ${os.version()}\n`); + test('returns audio devices', async t => { const devices = await audioDevices(); - console.log('Audio devices:', devices); t.true(Array.isArray(devices)); @@ -24,7 +26,6 @@ test('returns audio devices', async t => { test('returns screens', async t => { const monitors = await screens(); - console.log('Screens:', monitors); t.true(Array.isArray(monitors)); @@ -36,18 +37,26 @@ test('returns screens', async t => { test('returns available video codecs', t => { const codecs = videoCodecs; - console.log('Video codecs:', codecs); t.true(codecs.has('h264')); }); test('records screen', async t => { - await recorder.startRecording(); + if (os.arch() === 'x64') { + // The GH runner for x64 does not have screen capture permissions, so this fails + // The main purpose of the x64 runner is to make sure the binding if built correctly for cross-platform, + // so we are ok to skip this test + t.pass(); + return; + } + + const monitors = await screens(); + await recorder.startRecordingScreen({screenId: monitors[0].id}); t.true(fs.existsSync(await recorder.isFileReady)); await delay(1000); const videoPath = await recorder.stopRecording(); t.true(fs.existsSync(videoPath)); const buffer = await readChunk(videoPath, {length: 4100}); const fileType = await fileTypeFromBuffer(buffer); - t.is(fileType.ext, 'mov'); + t.is(fileType.ext, 'mp4'); fs.unlinkSync(videoPath); }); diff --git a/utils.js b/utils.js new file mode 100644 index 0000000..7dd66f0 --- /dev/null +++ b/utils.js @@ -0,0 +1,108 @@ +import os from 'node:os'; +import {temporaryFile} from 'tempy'; +import fileUrl from 'file-url'; + +export const supportsHevcHardwareEncoding = (() => { + const cpuModel = os.cpus()[0].model; + + // All Apple silicon Macs support HEVC hardware encoding. + if (cpuModel.startsWith('Apple ')) { + // Source string example: `'Apple M1'` + return true; + } + + // Get the Intel Core generation, the `4` in `Intel(R) Core(TM) i7-4850HQ CPU @ 2.30GHz` + // More info: https://www.intel.com/content/www/us/en/processors/processor-numbers.html + // Example strings: + // - `Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz` + // - `Intel(R) Core(TM) i7-4850HQ CPU @ 2.30GHz` + const result = /Intel.*Core.*i\d+-(\d)/.exec(cpuModel); + + // Intel Core generation 6 or higher supports HEVC hardware encoding + return result && Number.parseInt(result[1], 10) >= 6; +})(); + +export const videoCodecs = new Map([ + ['h264', 'H264'], + ['hevc', 'HEVC'], + ['proRes422', 'Apple ProRes 422'], + ['proRes4444', 'Apple ProRes 4444'], +]); + +if (!supportsHevcHardwareEncoding) { + videoCodecs.delete('hevc'); +} + +export function normalizeOptions(targetType, { + targetId = undefined, + fps = 30, + cropArea = undefined, + showCursor = true, + highlightClicks = false, + audioDeviceId = undefined, + videoCodec = 'h264', + losslessAudio = false, + systemAudio = false, + extension = videoCodec === 'proRes422' || videoCodec === 'proRes4444' ? 'mov' : 'mp4', +} = {}) { + const recorderOptions = { + targetId, + framesPerSecond: fps, + showCursor, + highlightClicks, + audioDeviceId, + losslessAudio, + recordSystemAudio: systemAudio, + }; + + if (videoCodec && targetType !== 'audioOnly') { + const codecMap = new Map([ + ['h264', ['mp4', 'mov', 'm4v']], + ['hevc', ['mp4', 'mov', 'm4v']], + ['proRes422', ['mov']], + ['proRes4444', ['mov']], + ]); + + if (!supportsHevcHardwareEncoding) { + codecMap.delete('hevc'); + } + + const allowedExtensions = codecMap.get(videoCodec); + + if (!allowedExtensions) { + throw new Error(`Unsupported video codec specified: ${videoCodec}`); + } + + if (!allowedExtensions.includes(extension)) { + throw new Error(`The video codec ${videoCodec} does not support the extension ${extension}. Allowed extensions: ${allowedExtensions.join(', ')}`); + } + + recorderOptions.videoCodec = videoCodec; + } + + if (targetType === 'audioOnly' && extension !== 'm4a') { + throw new Error('Audio recordings only supports the m4a extension'); + } + + const temporaryPath = temporaryFile({ + extension: targetType === 'audioOnly' ? 'm4a' : extension, + }); + + recorderOptions.destination = fileUrl(temporaryPath); + + if (highlightClicks === true) { + recorderOptions.showCursor = true; + } + + if (targetType === 'screen' && cropArea) { + recorderOptions.cropRect = [ + [cropArea.x, cropArea.y], + [cropArea.width, cropArea.height], + ]; + } + + return { + tmpPath: temporaryPath, + recorderOptions, + }; +}