Skip to content

Rewrite with ScreenCaptureKit - #80

Merged
karaggeorge merged 21 commits into
mainfrom
george/rewrite-in-screen-capture-kit
Nov 22, 2024
Merged

Rewrite with ScreenCaptureKit#80
karaggeorge merged 21 commits into
mainfrom
george/rewrite-in-screen-capture-kit

Conversation

@karaggeorge

@karaggeorge karaggeorge commented Nov 10, 2024

Copy link
Copy Markdown
Member

Rewrites Aperture to use ScreenCaptureKit

Breaking changes:

  • Now requires macOS 13+
  • highlightClicks is only available on macOS 15+
  • Aperture itself is not the recorder, you can instantiate Aperture.Recorder() and use startRecording() and stopRecording() on it
  • Everything uses async/await instead of callbacks
  • startRecording awaits until the file actually starts getting written before resolving, or throws any errors that happen during that time
  • cropRect is top/left now (this is how ScreenCaptureKit works by default)

This has as much feature parity as possible (other then the highlightClicks note above) but it adds some functionality as well:

  • Also supports .mov and .m4v for videos
  • Recording system audio (+ mic)
  • Loseless audio option
  • Recording audio only (only .m4a for now, AAC or ALAC encoding)
  • Recording a specific window (will follow the window around even if it moves or covered by other windows)

Potential features we'd want to add in a follow-up:

  • Support more audio formats
  • Maybe support quality/bitrate options for audio/video
  • Support recording applications as targets
  • Support excluding apps/windows from the recording (for screen/application only)
    • Some good default sets would be excluding the current application itself and/or desktop icons

This PR still needs to update the readme, but wanted to get it reviewed first to fully land on the implementation before I go and update the readme

Closes #16
Closes #31
Closes #71
Closes #63
Closes #65


IssueHunt Summary

Referenced issues

This pull request has been submitted to:


Comment thread Sources/Aperture/Aperture.swift Outdated
import AVFoundation
import ScreenCaptureKit

public final class Aperture {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is simply a namespace, make it enum instead.

Comment thread Sources/Aperture/Aperture.swift Outdated
public struct RecordingOptions {
public init(
destination: URL,
targetId: String? = nil,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
targetId: String? = nil,
targetID: String? = nil,

to match Swift style. Applies in other places too.

@karaggeorge karaggeorge Nov 10, 2024

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! These are great. Took some courses on Swift before I wrote this to get a better understanding, but they don't really cover formatting

Is there something like eslint/prettier for Swift that can help me identify these? (and things like not adding : Bool if there's a default value etc)

Using XCode fwiw, but I can use VSCode too if there's something available there

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread Sources/Aperture/Aperture.swift Outdated
case audioOnly
}

public enum ApertureError: Swift.Error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public enum ApertureError: Swift.Error {
public enum Error: Swift.Error {

Comment thread Sources/Aperture/Aperture.swift Outdated
case noTargetProvided
case invalidFileExtension(String, Bool)
case noDisplaysConnected
case unknownError(Swift.Error)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
case unknownError(Swift.Error)
case unknown(Swift.Error)

And should be last.

Comment thread Sources/Aperture/Aperture.swift Outdated
Comment on lines +93 to +95
private var isRunning: Bool = false
/// Whether the recorder is paused
private var isPaused: Bool = false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like these would be better as a single enum. Maybe isStreamRecording should also be included.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can I do this with an enum? Do you mean struct to hold each var? They can have independent values at times

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can it be isRunning == true and isPaused == true at the same time?

Comment thread Sources/Aperture/Aperture.swift Outdated
externalDeviceCaptureSession.stopRunning()
}

if (assetWriter?.status == .writing) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (assetWriter?.status == .writing) {
if assetWriter?.status == .writing {

Comment thread Sources/Aperture/Aperture.swift Outdated
audioDevice: audioDevice,
videoCodec: videoCodec
)
extension Aperture.Recorder: SCStreamDelegate, SCStreamOutput, AVCaptureAudioDataOutputSampleBufferDelegate, AVCaptureVideoDataOutputSampleBufferDelegate {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If possible, one extension per conformance. Makes it easier to read.

Comment thread Sources/Aperture/Aperture.swift Outdated
private func initOutput(target: Aperture.Target, options: Aperture.RecordingOptions, streamConfig: SCStreamConfiguration) async throws {
let assetWriter = try getAssetWriter(target: target, options: options)

var audioSettings: [String: Any] = [AVSampleRateKey : 48000, AVNumberOfChannelsKey : 2]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
var audioSettings: [String: Any] = [AVSampleRateKey : 48000, AVNumberOfChannelsKey : 2]
var audioSettings: [String: Any] = [AVSampleRateKey: 48000, AVNumberOfChannelsKey: 2]

case unknownError(Swift.Error)
case noPermissions
}
public final class Recorder: NSObject {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Define this in an extension for improved readability.

Comment thread Sources/Aperture/Aperture.swift Outdated

/// Internal helpers for when we are resuming, used to fix the buffer timing
private var isResuming: Bool = false
private var timeOffset: CMTime = .zero

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private var timeOffset: CMTime = .zero
private var timeOffset = CMTime.zero

Comment thread Sources/Aperture/Utilities.swift Outdated

service = IOIteratorNext(iterator)
extension Aperture {
public static func hasPermissions() async -> Bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public static func hasPermissions() async -> Bool {
public static var hasPermissions: Bool {
get async {

Comment thread Sources/Aperture/Utilities.swift Outdated
return "Unnamed screen"
extension CMSampleBuffer {
public func adjustTime(by offset: CMTime) -> CMSampleBuffer? {
guard CMSampleBufferGetFormatDescription(self) != nil else { return nil }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use self.formatDescription. Same with the other things.

@karaggeorge

Copy link
Copy Markdown
Member Author

@sindresorhus addressed all except for this and configured/ran swiftlint 😌

@karaggeorge

Copy link
Copy Markdown
Member Author

While working on aperture-node with this version, I noticed a few things missing. Pushed some commits above, but need to also add:

  • Support for proRes codecs
  • Error handling if we can't add the video inputs (we silently fail right now, took me a while to figure out why prores wasn't working)

will be pushing a commit with those two at some point tomorrow

@karaggeorge

Copy link
Copy Markdown
Member Author

Ok, I think it's ready:

  • Added support for proRes
    • Created a scoped enum for the ones we support and an extension to map them
    • Added checks for invalid file extensions <-> codec
  • Added better error handling
    • Fixed some errors to have clearer message
    • Fixed logging for error sub-reasons
    • Throw errors if the stream does not start instead of failing silently or crashing
  • Added a reset to the class so it can be re-used after recording is done

Comment thread Sources/Aperture/Devices.swift Outdated
if let mode = CGDisplayCopyDisplayMode(self.displayID) {
return mode.pixelWidth / mode.width
}
return 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return 1
return 1


public func fileOutputShouldProvideSampleAccurateRecordingStart(_ output: AVCaptureFileOutput) -> Bool { true }
if output is AVCaptureVideoDataOutput {
if assetWriter != nil, !isRunning {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if assetWriter != nil, !isRunning {
if
assetWriter != nil,
!isRunning
{

Comment thread Sources/Aperture/Aperture.swift Outdated
let sampleBuffer = handleBuffer(buffer: sampleBuffer, isVideo: output is AVCaptureVideoDataOutput)

if output is AVCaptureAudioDataOutput {
if assetWriter != nil && !isRunning && target == .audioOnly {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if assetWriter != nil && !isRunning && target == .audioOnly {
if
assetWriter != nil,
!isRunning,
target == .audioOnly
{

Same in many places. Prefer , over && in if/guard statements.

Comment thread Sources/Aperture/Aperture.swift Outdated

extension Aperture.Recorder: AVCaptureAudioDataOutputSampleBufferDelegate, AVCaptureVideoDataOutputSampleBufferDelegate {
public func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
if isPaused {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if isPaused {
guard !isPaused else {

Comment thread Sources/Aperture/Aperture.swift Outdated
Comment on lines +741 to +746
if isPaused {
return
}
guard sampleBuffer.isValid else {
return
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be combined.

get { activity != nil }
set {
if newValue {
activity = ProcessInfo.processInfo.beginActivity(options: .idleSystemSleepDisabled, reason: "Recording screen")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The activity handling could be simplified with something like this:

final class Activity {
	private let activity: NSObjectProtocol

	init(
		_ options: ProcessInfo.ActivityOptions = [],
		reason: String
	) {
		self.activity = ProcessInfo.processInfo.beginActivity(options: options, reason: reason)
	}

	deinit {
		ProcessInfo.processInfo.endActivity(activity)
	}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the old code, in the new version we just set it once on start, and then stop/remove it in the cleanup. Feels pretty simple, but I could move it to a separate class if you want

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The benefit of the class is that it's self-contained you can just nil the property in the recorder to end the activity.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. Updated.

I think all the comments are addressed, I'm going to start working on the readme updates

Comment thread Sources/Aperture/Aperture.swift Outdated
}
if lastFrame.flags.contains(.valid) {
if timeOffset.value > 0 {
pts = CMTimeSubtract(pts, timeOffset)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CMTime support + and -

Comment thread Sources/Aperture/Aperture.swift Outdated
}

var lastFrame = CMSampleBufferGetPresentationTimeStamp(resultBuffer)
let dur = CMSampleBufferGetDuration(resultBuffer)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't use abbreviations

Comment thread Sources/Aperture/Aperture.swift Outdated
if isResuming {
isResuming = false

var pts = CMSampleBufferGetPresentationTimeStamp(buffer)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No abbreviations.

Comment thread Sources/Aperture/Aperture.swift Outdated
resultBuffer = resultBuffer.adjustTime(by: timeOffset) ?? resultBuffer
}

var lastFrame = CMSampleBufferGetPresentationTimeStamp(resultBuffer)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use sampleBuffer instance methods, not the legacy global methods. Applies in many places.

@sindresorhus

Copy link
Copy Markdown
Contributor

You don't need to nil it in the clean up. It will end when the RecordingSession class is nil'd. However, should the activity be ended when paused?

@karaggeorge

karaggeorge commented Nov 17, 2024

Copy link
Copy Markdown
Member Author

@sindresorhus Updated 👍

I think it's ok to keep during pause, not sure what would happen if it went to sleep, then resumed after

Also updated the readme. We didn't have docs before, added some basic usage and options

Comment thread readme.md Outdated
<p align="center">Record the screen on macOS</p>
<img src="Media/aperture-logo.svg" width="64" height="64">
<h3 align="center">Aperture</h3>
<p align="center">Record the screen on macOS</p>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect indentation.

Comment thread readme.md Outdated
options: Aperture.RecordingOptions(
destination: URL(fileURLWithPath: "./screen-recording.mp4"),
targetID: screen.id,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect indentation.

Comment thread readme.md Outdated
Comment on lines +111 to +113
Type: `Bool`

Default: `false`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Type: `Bool`
Default: `false`
Type: `Bool`\
Default: `false`

@sindresorhus

Copy link
Copy Markdown
Contributor

Instead of documenting the API in the readme, it would be better to use DocC and add good doc comments to the methods.

You just need https://github.com/sindresorhus/Defaults/blob/main/.spi.yml and get it added to https://swiftpackageindex.com/add-a-package

Comment thread Example/Sources/Example/main.swift Outdated
@@ -7,32 +7,47 @@ func delay(seconds: TimeInterval, closure: @escaping () -> Void) {
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delay is unused.

Comment thread Example/Sources/Example/main.swift Outdated
@@ -7,32 +7,47 @@ func delay(seconds: TimeInterval, closure: @escaping () -> Void) {
}

let url = URL(fileURLWithPath: "../screen-recording.mp4")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let url = URL(fileURLWithPath: "../screen-recording.mp4")
let url = URL(filePath: "../screen-recording.mp4")

print("Finished recording:", url.path)
exit(0)
} catch let error as Aperture.Error {
print("Aperture Error: \(error.localizedDescription)")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should exit here too.

Comment thread Package.swift
@@ -1,10 +1,10 @@
// swift-tools-version:5.5
// swift-tools-version:5.7

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why such an old Xcode version?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't know that was an xcode version to be honest. I updated the minimum version to v13 and it said this has to be at least 5.7, so I changed it to fix

I'll update to 6.0.2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make it "5.11". 6 will trigger a lot of concurrency things we don't want to deal with yet.

Comment thread Sources/Aperture/Aperture.swift Outdated
Comment on lines +341 to +346
let finalError: Aperture.Error
if let error = error as? Aperture.Error {
finalError = error
} else {
finalError = Error.couldNotStartStream(error)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let finalError: Aperture.Error
if let error = error as? Aperture.Error {
finalError = error
} else {
finalError = Error.couldNotStartStream(error)
}
let finalError = if let error = error as? Error {
error
} else {
.couldNotStartStream(error)
}

Comment thread Sources/Aperture/Devices.swift Outdated
Comment on lines +50 to +51
public let applicationName: String?
public let applicationBundleIdentifier: String?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public let applicationName: String?
public let applicationBundleIdentifier: String?
public let appName: String?
public let appBundleIdentifier: String?

Comment thread Sources/Aperture/Aperture.swift Outdated
/// The error handler for the recording session
var onError: ((Error) -> Void)?

func startRecording(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
func startRecording(
func start(

It's inconsistent with pause and the recorder part is already clear from the namespace. recorder.start()

Comment thread Sources/Aperture/Aperture.swift Outdated

filter = screenFilter
case .window:
/// We need to call this before `SCContentFilter` below otherwise an error is thrown: https://forums.developer.apple.com/forums/thread/743615

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only documentation comments should use ///. Applies in many places.

throw Error.couldNotAddScreen
extension Aperture {
internal final class RecordingSession: NSObject {
/// The stream object for capturing anything on displays

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// The stream object for capturing anything on displays
// The stream object for capturing anything on displays

Comment thread Sources/Aperture/Aperture.swift Outdated
} else {
throw Error.couldNotAddScreen
extension Aperture {
internal final class RecordingSession: NSObject {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
internal final class RecordingSession: NSObject {
final class RecordingSession: NSObject {

internal is the default.

@karaggeorge

karaggeorge commented Nov 20, 2024

Copy link
Copy Markdown
Member Author

@sindresorhus re: docs, do I just need the /// comments above the exported methods and to add that file? Any way to check what it looks like locally?

I've added those for most properties, but that class is internal now. The "exposed" one only has start/stop/pause/resume and the onStart etc handlers

If it's more work than that ^, can we do it in a follow-up?

Addressed the rest of the feedback

@sindresorhus

Copy link
Copy Markdown
Contributor

do I just need the /// comments above the exported methods and to add that file? Any way to check what it looks like locally?

Yes, although I prefer /** */ doc comments without the * line prefix as it makes it much easier to edit.

You can preview the docs in Xcode. Click "Product" and then "Build Documentation".

Comment thread readme.md Outdated

guard let screen = screens.first else {
// No screens
exit(1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tab indentation, to match the code.

Comment thread readme.md Outdated
try await recorder.startRecording(
target: .screen,
options: Aperture.RecordingOptions(
destination: URL(fileURLWithPath: "./screen-recording.mp4"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
destination: URL(fileURLWithPath: "./screen-recording.mp4"),
destination: URL(filePath: "screen-recording.mp4"),

Comment thread readme.md Outdated
exit(1)
}

try await recorder.startRecording(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
try await recorder.startRecording(
try await recorder.start(

Comment thread readme.md Outdated

try await Task.sleep(for: .seconds(5))

try await recorder.stopRecording()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
try await recorder.stopRecording()
try await recorder.stop()

Comment thread readme.md Outdated
```swift
let screens = try await Aperture.Devices.screen()
```
<!-- Aperture.Devices.window(excludeDesktopWindows: false, onScreenWindowsOnly: false) -->

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leftover

@karaggeorge

Copy link
Copy Markdown
Member Author

@sindresorhus added in-line docs and added a link to the readme for swift package index (I kept the other readme sections as well)

I'm not sure if I can submit it to package index until after it's merged to main, but also figured the text might need changes

@sindresorhus sindresorhus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look good to me. Nice work! 👍

@karaggeorge
karaggeorge merged commit 7591bb5 into main Nov 22, 2024
@karaggeorge
karaggeorge deleted the george/rewrite-in-screen-capture-kit branch November 22, 2024 13:59
@karaggeorge

karaggeorge commented Nov 22, 2024

Copy link
Copy Markdown
Member Author

@sindresorhus Thanks for all the reviews!

Is there anything special about releasing? Or just cut a release tag using the GH UI?
It looks like SPM clones these from GH, but I'm not sure

Edit: Released https://github.com/wulkano/Aperture/releases/tag/v3.0.0 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Update usage of deprecated methods Support for top/left positions for capture Code comments Internal Audio Allow to choose window as source?

2 participants