diff --git a/.gitignore b/.gitignore index 7c83b10..da4a8b2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,10 @@ xcuserdata /Packages /*.xcodeproj /aperture +/aperture.node recording.mp4 + + +# SwiftLint Remote Config Cache +.swiftlint/RemoteConfigCache \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..1432074 --- /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 \ No newline at end of file diff --git a/Package.resolved b/Package.resolved index db40f48..44560f8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wulkano/Aperture", "state" : { - "revision" : "ddfb0fc1b3c789339dd5fd9296ba8076d292611c", - "version" : "2.0.1" + "branch" : "george/rewrite-in-screen-capture-kit", + "revision" : "6a3adffa8b3af3fd766e581bddf2c4416bf4547a" } }, { @@ -14,8 +14,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "46989693916f56d1186bd59ac15124caef896560", - "version" : "1.3.1" + "revision" : "41982a3656a71c768319979febd796c6fd111d5c", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" } } ], diff --git a/Package.swift b/Package.swift index 8a9be6f..453b8a5 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( name: "ApertureCLI", platforms: [ - .macOS(.v10_13) + .macOS(.v13) ], products: [ .executable( @@ -12,11 +12,17 @@ let package = Package( targets: [ "ApertureCLI" ] + ), + .library( + name: "aperture-module", + type: .dynamic, + targets: ["ApertureModule"] ) ], 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", branch: "george/rewrite-in-screen-capture-kit"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1"), + .package(path: "node_modules/node-swift") ], targets: [ .executableTarget( @@ -25,6 +31,14 @@ let package = Package( "Aperture", .product(name: "ArgumentParser", package: "swift-argument-parser") ] + ), + .target( + name: "ApertureModule", + dependencies: [ + "Aperture", + .product(name: "NodeAPI", package: "node-swift"), + .product(name: "NodeModuleSupport", package: "node-swift") + ] ) ] ) diff --git a/Sources/ApertureCLI/ApertureCLI.swift b/Sources/ApertureCLI/ApertureCLI.swift index 94fb4d7..42518a2 100644 --- a/Sources/ApertureCLI/ApertureCLI.swift +++ b/Sources/ApertureCLI/ApertureCLI.swift @@ -10,6 +10,13 @@ enum OutEvent: String, CaseIterable, ExpressibleByArgument { case onFinish } +enum TargetType: String, CaseIterable, ExpressibleByArgument { + case screen + case window + case audio + case externalDevice +} + enum InEvent: String, CaseIterable, ExpressibleByArgument { case pause case resume @@ -40,7 +47,9 @@ extension ApertureCLI { static let configuration = CommandConfiguration( subcommands: [ Screens.self, - AudioDevices.self + AudioDevices.self, + Windows.self, + ExternalDevices.self ] ) } @@ -51,11 +60,23 @@ extension ApertureCLI { @Option(name: .shortAndLong, help: "The ID to use for this process") var processId = "main" + @Option(name: .shortAndLong, help: "The type of target to record") + var targetType = TargetType.screen + @Argument(help: "Stringified JSON object with options passed to Aperture") var options: String mutating func run() throws { - try record(options, processId: processId) + Task { [self] in + do { + try await record(options, processId: processId, targetType: targetType) + } catch { + print(error, to: .standardError) + Darwin.exit(1) + } + } + + RunLoop.main.run() } } @@ -75,8 +96,67 @@ extension ApertureCLI.List { 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) + Task { + // Uses stderr because of unrelated stuff being outputted on stdout. + print( + try toJson( + await Aperture.Devices.screen().map { + [ + "id": $0.id, + "name": $0.name, + "width": $0.width, + "height": $0.height, + "frame": $0.frame.asDictionary + ] + } + ), + to: .standardError + ) + Darwin.exit(0) + } + + RunLoop.main.run() + } + } + + struct Windows: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "List available windows.") + + @Flag(inversion: .prefixedNo, help: "Exclude desktop windows") + var excludeDesktopWindows = true + + @Flag(inversion: .prefixedNo, help: "Only include windows that are on screen") + var onScreenOnly = true + + mutating func run() throws { + Task { [self] in + // Uses stderr because of unrelated stuff being outputted on stdout. + print( + try toJson( + await Aperture.Devices.window( + excludeDesktopWindows: excludeDesktopWindows, + onScreenWindowsOnly: onScreenOnly + ) + .map { + [ + "id": $0.id, + "title": $0.title as Any, + "applicationName": $0.applicationName as Any, + "applicationBundleIdentifier": $0.applicationBundleIdentifier as Any, + "isActive": $0.isActive, + "isOnScreen": $0.isOnScreen, + "layer": $0.layer, + "frame": $0.frame.asDictionary + ] + } + ), + to: .standardError + ) + + Darwin.exit(0) + } + + RunLoop.main.run() } } @@ -88,6 +168,15 @@ extension ApertureCLI.List { print(try toJson(Aperture.Devices.audio().map { ["id": $0.id, "name": $0.name] }), to: .standardError) } } + + struct ExternalDevices: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "List available external devices.") + + mutating func run() throws { + // Uses stderr because of unrelated stuff being outputted on stdout. + print(try toJson(Aperture.Devices.iOS().map { ["id": $0.id, "name": $0.name] }), to: .standardError) + } + } } extension ApertureCLI.Events { diff --git a/Sources/ApertureCLI/Utilities.swift b/Sources/ApertureCLI/Utilities.swift index 4033527..326d527 100644 --- a/Sources/ApertureCLI/Utilities.swift +++ b/Sources/ApertureCLI/Utilities.swift @@ -260,3 +260,14 @@ func toJson(_ data: T) throws -> String { return String(data: json, encoding: .utf8)! } // MARK: - + +extension CGRect { + var asDictionary: [String: Any] { + [ + "x": Int(origin.x), + "y": Int(origin.y), + "width": Int(size.width), + "height": Int(size.height) + ] + } +} diff --git a/Sources/ApertureCLI/record.swift b/Sources/ApertureCLI/record.swift index a815614..7c509d0 100644 --- a/Sources/ApertureCLI/record.swift +++ b/Sources/ApertureCLI/record.swift @@ -3,30 +3,22 @@ import Aperture struct Options: Decodable { let destination: URL + let targetId: String? let framesPerSecond: Int let cropRect: CGRect? let showCursor: Bool let highlightClicks: Bool - let screenId: CGDirectDisplayID let audioDeviceId: String? let videoCodec: String? + let losslessAudio: Bool + let recordSystemAudio: Bool } -func record(_ optionsString: String, processId: String) throws { - setbuf(__stdoutp, nil) +func record(_ optionsString: String, processId: String, targetType: TargetType) async throws { 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 - ) + let recorder = Aperture.Recorder() recorder.onStart = { ApertureEvents.sendEvent(processId: processId, event: OutEvent.onFileReady.rawValue) @@ -40,16 +32,12 @@ func record(_ optionsString: String, processId: String) throws { 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) - } + recorder.onError = { + print($0, to: .standardError) + exit(1) + } + recorder.onFinish = { ApertureEvents.sendEvent(processId: processId, event: OutEvent.onFinish.rawValue) for observer in observers { @@ -60,7 +48,9 @@ func record(_ optionsString: String, processId: String) throws { } CLI.onExit = { - recorder.stop() + Task { + try await recorder.stopRecording() + } // Do not call `exit()` here as the video is not always done // saving at this point and will be corrupted randomly } @@ -83,8 +73,41 @@ func record(_ optionsString: String, processId: String) throws { } ) - recorder.start() - ApertureEvents.sendEvent(processId: processId, event: OutEvent.onStart.rawValue) + let videoCodec: Aperture.VideoCodec + if let videoCodecString = options.videoCodec { + videoCodec = try .fromRawValue(videoCodecString) + } else { + videoCodec = .h264 + } + + let target: Aperture.Target + + switch targetType { + case .screen: + target = .screen + case .window: + target = .window + case .audio: + target = .audioOnly + case .externalDevice: + target = .externalDevice + } - RunLoop.main.run() + try await recorder.startRecording( + target: target, + options: Aperture.RecordingOptions( + destination: options.destination, + targetID: options.targetId, + framesPerSecond: options.framesPerSecond, + cropRect: options.cropRect, + showCursor: options.showCursor, + highlightClicks: options.highlightClicks, + videoCodec: videoCodec, + losslessAudio: options.losslessAudio, + recordSystemAudio: options.recordSystemAudio, + microphoneDeviceID: options.audioDeviceId != nil ? options.audioDeviceId : nil + ) + ) + + ApertureEvents.sendEvent(processId: processId, event: OutEvent.onStart.rawValue) } diff --git a/Sources/ApertureModule/ApertureModule.swift b/Sources/ApertureModule/ApertureModule.swift new file mode 100644 index 0000000..22512f3 --- /dev/null +++ b/Sources/ApertureModule/ApertureModule.swift @@ -0,0 +1,310 @@ +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 startRecording(_ 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.startRecording(target: target, options: options.asOptions()) + } + + @NodeActor + @NodeMethod + func stopRecording() async throws { + try await self.recorder.stopRecording() + } + + @NodeActor + @NodeMethod + func pause() { + self.recorder.pause() + } + + @NodeActor + @NodeMethod + func resume() { + 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(), + "applicationName": self.applicationName, + "applicationBundleIdentifier": self.applicationBundleIdentifier, + "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/common.js b/common.js new file mode 100644 index 0000000..539e5c8 --- /dev/null +++ b/common.js @@ -0,0 +1,104 @@ +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 !== 'audio') { + 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; + } + + const temporaryPath = temporaryFile({ + extension: targetType === 'audio' ? '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, + }; +} diff --git a/index.d.ts b/index.d.ts index 51162c6..c54b93d 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; + applicationName?: string; + applicationBundleIdentifier?: 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', + applicationName: 'Safari', + applicationBundleIdentifier: '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..8b12045 100644 --- a/index.js +++ b/index.js @@ -1,13 +1,13 @@ -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 {assertMacOSVersionGreaterThanOrEqualTo} from 'macos-version'; -import fileUrl from 'file-url'; import {fixPathForAsarUnpack} from 'electron-util/node'; import delay from 'delay'; +import {normalizeOptions} from './common.js'; + +export {videoCodecs} from './common.js'; const log = debuglog('aperture'); const getRandomId = () => Math.random().toString(36).slice(2, 15); @@ -16,40 +16,55 @@ 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 supportsHevcHardwareEncoding = (() => { - const cpuModel = os.cpus()[0].model; +export class Recorder { + constructor() { + assertMacOSVersionGreaterThanOrEqualTo('13'); + } - // All Apple silicon Macs support HEVC hardware encoding. - if (cpuModel.startsWith('Apple ')) { - // Source string example: `'Apple M1'` - return true; + startRecordingScreen({ + screenId, + ...options + }) { + return this._startRecording('screen', { + ...options, + targetId: screenId, + }); } - // 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); + startRecordingWindow({ + windowId, + ...options + }) { + return this._startRecording('window', { + ...options, + targetId: windowId, + }); + } - // Intel Core generation 6 or higher supports HEVC hardware encoding - return result && Number.parseInt(result[1], 10) >= 6; -})(); + startRecordingExternalDevice({ + deviceId, + ...options + }) { + return this._startRecording('externalDevice', { + ...options, + targetId: deviceId, + }); + } -class Recorder { - constructor() { - assertMacOSVersionGreaterThanOrEqualTo('10.13'); + startRecordingAudio({ + audioDeviceId, + losslessAudio, + systemAudio, + }) { + return this._startRecording('audio', { + audioDeviceId, + losslessAudio, + systemAudio, + extension: 'm4a', + }); } - startRecording({ - fps = 30, - cropArea = undefined, - showCursor = true, - highlightClicks = false, - screenId = 0, - audioDeviceId = undefined, - videoCodec = 'h264', - } = {}) { + _startRecording(targetType, options) { this.processId = getRandomId(); return new Promise((resolve, reject) => { @@ -58,57 +73,9 @@ class Recorder { return; } - this.tmpPath = temporaryFile({extension: 'mp4'}); - - if (highlightClicks === true) { - showCursor = true; - } - - 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; - } - - const recorderOptions = { - destination: fileUrl(this.tmpPath), - framesPerSecond: fps, - showCursor, - highlightClicks, - screenId, - audioDeviceId, - }; - - 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'); - } + const {tmpPath, recorderOptions} = normalizeOptions(targetType, options); - if (!codecMap.has(videoCodec)) { - throw new Error(`Unsupported video codec specified: ${videoCodec}`); - } - - recorderOptions.videoCodec = codecMap.get(videoCodec); - } + this.tmpPath = tmpPath; const timeout = setTimeout(() => { // `.stopRecording()` was called already @@ -142,6 +109,8 @@ class Recorder { 'record', '--process-id', this.processId, + '--target-type', + targetType, JSON.stringify(recorderOptions), ]); @@ -237,6 +206,24 @@ export const screens = async () => { } }; +export const windows = async ({ + excludeDesktopWindows = true, + onScreenOnly = true, +} = {}) => { + const {stderr} = await execa(BINARY, [ + 'list', + 'windows', + excludeDesktopWindows ? '--exclude-desktop-windows' : '--no-exclude-desktop-windows', + onScreenOnly ? '--on-screen-only' : '--no-on-screen-only', + ]); + + try { + return JSON.parse(removeWarnings(stderr)); + } catch (error) { + throw new Error(stderr, {cause: error}); + } +}; + export const audioDevices = async () => { const {stderr} = await execa(BINARY, ['list', 'audio-devices']); @@ -247,13 +234,12 @@ export const audioDevices = async () => { } }; -export const videoCodecs = new Map([ - ['h264', 'H264'], - ['hevc', 'HEVC'], - ['proRes422', 'Apple ProRes 422'], - ['proRes4444', 'Apple ProRes 4444'], -]); +export const externalDevices = async () => { + const {stderr} = await execa(BINARY, ['list', 'external-devices']); -if (!supportsHevcHardwareEncoding) { - videoCodecs.delete('hevc'); -} + try { + return JSON.parse(removeWarnings(stderr)); + } catch (error) { + throw new Error(stderr, {cause: error}); + } +}; 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/native.js b/native.js new file mode 100644 index 0000000..f2ae93c --- /dev/null +++ b/native.js @@ -0,0 +1,146 @@ +import {createRequire} from 'node:module'; +import {assertMacOSVersionGreaterThanOrEqualTo} from 'macos-version'; +import {normalizeOptions} from './common.js'; + +export {videoCodecs} from './common.js'; + +const nativeModule = createRequire(import.meta.url)('./aperture.node'); + +export class Recorder { + constructor() { + assertMacOSVersionGreaterThanOrEqualTo('13'); + } + + startRecordingScreen({ + screenId, + ...options + }) { + return this._startRecording('screen', { + ...options, + targetId: screenId, + }); + } + + startRecordingWindow({ + windowId, + ...options + }) { + return this._startRecording('window', { + ...options, + targetId: windowId, + }); + } + + startRecordingExternalDevice({ + deviceId, + ...options + }) { + return this._startRecording('externalDevice', { + ...options, + targetId: deviceId, + }); + } + + startRecordingAudio({ + audioDeviceId, + losslessAudio, + systemAudio, + }) { + return this._startRecording('audio', { + audioDeviceId, + losslessAudio, + systemAudio, + extension: 'm4a', + }); + } + + async _startRecording(targetType, options) { + if (this.recorder !== undefined) { + throw new Error('Call `.stopRecording()` first'); + } + + const {tmpPath, recorderOptions} = normalizeOptions(targetType, options); + + this.tmpPath = tmpPath; + this.recorder = new nativeModule.Recorder(); + + this.isFileReady = new Promise(resolve => { + this.recorder.onStart = () => { + resolve(this.tmpPath); + }; + }); + + 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 (targetType === 'screen' && options.cropArea) { + finalOptions.cropRect = options.cropArea; + } + + if (recorderOptions.targetId) { + finalOptions.targetId = recorderOptions.targetId; + } + + if (recorderOptions.audioDeviceId) { + finalOptions.microphoneDeviceID = recorderOptions.audioDeviceId; + } + + console.log(finalOptions); + + await this.recorder.startRecording(targetType === 'audio' ? 'audioOnly' : targetType, finalOptions); + } + + throwIfNotStarted() { + if (this.recorder === undefined) { + throw new Error('Call `.startRecording()` first'); + } + } + + async pause() { + this.throwIfNotStarted(); + this.recorder.pause(); + } + + async resume() { + this.throwIfNotStarted(); + this.recorder.resume(); + } + + async isPaused() { + this.throwIfNotStarted(); + return this.recorder.isPaused(); + } + + async stopRecording() { + this.throwIfNotStarted(); + await this.recorder.stopRecording(); + + delete this.recorder; + delete this.isFileReady; + + return this.tmpPath; + } +} + +export const recorder = new Recorder(); + +export const screens = async () => nativeModule.getScreens(); + +export const windows = async ({ + excludeDesktopWindows = true, + onScreenOnly = true, +} = {}) => nativeModule.getWindows(excludeDesktopWindows, onScreenOnly); + +export const audioDevices = async () => nativeModule.getAudioDevices(); + +export const externalDevices = async () => nativeModule.getIOSDevices(); diff --git a/native.test.js b/native.test.js new file mode 100644 index 0000000..fbdf218 --- /dev/null +++ b/native.test.js @@ -0,0 +1,54 @@ +import fs from 'node:fs'; +import test from 'ava'; +import delay from 'delay'; +import {fileTypeFromBuffer} from 'file-type'; +import {readChunk} from 'read-chunk'; +import { + recorder, + audioDevices, + screens, + videoCodecs, +} from './native.js'; + +test('returns audio devices', async t => { + const devices = await audioDevices(); + console.log('Audio devices:', devices); + + t.true(Array.isArray(devices)); + + if (devices.length > 0) { + t.true(devices[0].id.length > 0); + t.true(devices[0].name.length > 0); + } +}); + +test('returns screens', async t => { + const monitors = await screens(); + console.log('Screens:', monitors); + + t.true(Array.isArray(monitors)); + + if (monitors.length > 0) { + t.true(monitors[0].id > 0); + t.true(monitors[0].name.length > 0); + } +}); + +test('returns available video codecs', t => { + const codecs = videoCodecs; + console.log('Video codecs:', codecs); + t.true(codecs.has('h264')); +}); + +test('records screen', async t => { + 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, 'mp4'); + fs.unlinkSync(videoPath); +}); diff --git a/package.json b/package.json index 44a80ba..19b301a 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,14 @@ "repository": "wulkano/aperture-node", "type": "module", "exports": { - "types": "./index.d.ts", - "default": "./index.js" + ".": { + "types": "./index.d.ts", + "default": "./index.js" + }, + "./native": { + "types": "./index.d.ts", + "default": "./native.js" + } }, "sideEffects": false, "engines": { @@ -15,13 +21,23 @@ }, "scripts": { "test": "xo && ava && tsd", - "build": "swift build --configuration=release --arch arm64 --arch x86_64 && mv .build/apple/Products/Release/aperture .", + "build": "npm run build:cli && npm run build:module", + "build:cli": "npm run build:cli:build && npm run build:cli:move", + "build:cli:build": "swift build --configuration=release --product aperture --arch arm64 --arch x86_64", + "build:cli:move": "mv .build/apple/Products/Release/aperture .", + "build:module": "npm run build:module:build && npm run build:module:move && npm run build:module:sign", + "build:module:build": "swift build -c release --product aperture-module -Xlinker -undefined -Xlinker dynamic_lookup", + "build:module:move": "mv .build/arm64-apple-macosx/release/libaperture-module.dylib ./aperture.node", + "build:module:sign": "codesign -fs - ./aperture.node", "prepublish": "npm run build" }, "files": [ "index.js", + "index.d.ts", + "common.js", + "native.js", "aperture", - "index.d.ts" + "aperture.node" ], "dependencies": { "delay": "^6.0.0", @@ -34,8 +50,16 @@ "devDependencies": { "ava": "^6.1.2", "file-type": "^19.0.0", + "node-swift": "github:kabiroberai/node-swift#1.3.0", "read-chunk": "^4.0.3", "tsd": "^0.30.7", + "type-fest": "^4.26.1", "xo": "^0.58.0" + }, + "ava": { + "files": [ + "test.js", + "native.test.js" + ] } } diff --git a/test.js b/test.js index f060969..33e2d1e 100644 --- a/test.js +++ b/test.js @@ -41,13 +41,14 @@ test('returns available video codecs', t => { }); test('records screen', async t => { - await recorder.startRecording(); + 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); });