diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index c56882a..c6a5740 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -40,25 +40,3 @@ jobs: CODE_SIGNING_REQUIRED=NO \ | xcbeautify --renderer github-actions || true - test: - name: Test - runs-on: macos-26 - timeout-minutes: 15 - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Select Xcode version - run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app - - - name: Run tests - run: | - xcodebuild test \ - -project "${{ env.APP_NAME }}.xcodeproj" \ - -scheme "${{ env.SCHEME }}" \ - -configuration Debug \ - -destination "platform=macOS" \ - CODE_SIGN_IDENTITY="-" \ - CODE_SIGNING_REQUIRED=NO \ - | xcbeautify --renderer github-actions || true diff --git a/BetterCapture.xcodeproj/project.pbxproj b/BetterCapture.xcodeproj/project.pbxproj index b0b66cd..f40bfe5 100644 --- a/BetterCapture.xcodeproj/project.pbxproj +++ b/BetterCapture.xcodeproj/project.pbxproj @@ -10,27 +10,8 @@ 6C5C123D2F3893FE0082CE23 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 6C0990E12F2BE0C200D48100 /* Sparkle */; }; /* End PBXBuildFile section */ -/* Begin PBXContainerItemProxy section */ - 6C0990BD2F2BE0C200D48100 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 6C0990A72F2BE0C100D48100 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 6C0990AE2F2BE0C100D48100; - remoteInfo = BetterCapture; - }; - 6C0990C72F2BE0C200D48100 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 6C0990A72F2BE0C100D48100 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 6C0990AE2F2BE0C100D48100; - remoteInfo = BetterCapture; - }; -/* End PBXContainerItemProxy section */ - /* Begin PBXFileReference section */ 6C0990AF2F2BE0C100D48100 /* BetterCapture.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BetterCapture.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 6C0990BC2F2BE0C200D48100 /* BetterCaptureTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BetterCaptureTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 6C0990C62F2BE0C200D48100 /* BetterCaptureUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BetterCaptureUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -52,16 +33,6 @@ path = BetterCapture; sourceTree = ""; }; - 6C0990BF2F2BE0C200D48100 /* BetterCaptureTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = BetterCaptureTests; - sourceTree = ""; - }; - 6C0990C92F2BE0C200D48100 /* BetterCaptureUITests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = BetterCaptureUITests; - sourceTree = ""; - }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -73,20 +44,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 6C0990B92F2BE0C200D48100 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 6C0990C32F2BE0C200D48100 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -94,8 +51,6 @@ isa = PBXGroup; children = ( 6C0990B12F2BE0C100D48100 /* BetterCapture */, - 6C0990BF2F2BE0C200D48100 /* BetterCaptureTests */, - 6C0990C92F2BE0C200D48100 /* BetterCaptureUITests */, 6C0990B02F2BE0C100D48100 /* Products */, ); sourceTree = ""; @@ -104,8 +59,6 @@ isa = PBXGroup; children = ( 6C0990AF2F2BE0C100D48100 /* BetterCapture.app */, - 6C0990BC2F2BE0C200D48100 /* BetterCaptureTests.xctest */, - 6C0990C62F2BE0C200D48100 /* BetterCaptureUITests.xctest */, ); name = Products; sourceTree = ""; @@ -136,52 +89,6 @@ productReference = 6C0990AF2F2BE0C100D48100 /* BetterCapture.app */; productType = "com.apple.product-type.application"; }; - 6C0990BB2F2BE0C200D48100 /* BetterCaptureTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 6C0990D32F2BE0C200D48100 /* Build configuration list for PBXNativeTarget "BetterCaptureTests" */; - buildPhases = ( - 6C0990B82F2BE0C200D48100 /* Sources */, - 6C0990B92F2BE0C200D48100 /* Frameworks */, - 6C0990BA2F2BE0C200D48100 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 6C0990BE2F2BE0C200D48100 /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - 6C0990BF2F2BE0C200D48100 /* BetterCaptureTests */, - ); - name = BetterCaptureTests; - packageProductDependencies = ( - ); - productName = BetterCaptureTests; - productReference = 6C0990BC2F2BE0C200D48100 /* BetterCaptureTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 6C0990C52F2BE0C200D48100 /* BetterCaptureUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 6C0990D62F2BE0C200D48100 /* Build configuration list for PBXNativeTarget "BetterCaptureUITests" */; - buildPhases = ( - 6C0990C22F2BE0C200D48100 /* Sources */, - 6C0990C32F2BE0C200D48100 /* Frameworks */, - 6C0990C42F2BE0C200D48100 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 6C0990C82F2BE0C200D48100 /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - 6C0990C92F2BE0C200D48100 /* BetterCaptureUITests */, - ); - name = BetterCaptureUITests; - packageProductDependencies = ( - ); - productName = BetterCaptureUITests; - productReference = 6C0990C62F2BE0C200D48100 /* BetterCaptureUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -195,15 +102,7 @@ 6C0990AE2F2BE0C100D48100 = { CreatedOnToolsVersion = 26.0.1; }; - 6C0990BB2F2BE0C200D48100 = { - CreatedOnToolsVersion = 26.0.1; - TestTargetID = 6C0990AE2F2BE0C100D48100; - }; - 6C0990C52F2BE0C200D48100 = { - CreatedOnToolsVersion = 26.0.1; - TestTargetID = 6C0990AE2F2BE0C100D48100; }; - }; }; buildConfigurationList = 6C0990AA2F2BE0C100D48100 /* Build configuration list for PBXProject "BetterCapture" */; developmentRegion = en; @@ -223,8 +122,6 @@ projectRoot = ""; targets = ( 6C0990AE2F2BE0C100D48100 /* BetterCapture */, - 6C0990BB2F2BE0C200D48100 /* BetterCaptureTests */, - 6C0990C52F2BE0C200D48100 /* BetterCaptureUITests */, ); }; /* End PBXProject section */ @@ -237,20 +134,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 6C0990BA2F2BE0C200D48100 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 6C0990C42F2BE0C200D48100 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -261,34 +144,9 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 6C0990B82F2BE0C200D48100 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 6C0990C22F2BE0C200D48100 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ -/* Begin PBXTargetDependency section */ - 6C0990BE2F2BE0C200D48100 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 6C0990AE2F2BE0C100D48100 /* BetterCapture */; - targetProxy = 6C0990BD2F2BE0C200D48100 /* PBXContainerItemProxy */; - }; - 6C0990C82F2BE0C200D48100 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 6C0990AE2F2BE0C100D48100 /* BetterCapture */; - targetProxy = 6C0990C72F2BE0C200D48100 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 6C0990CE2F2BE0C200D48100 /* Debug */ = { @@ -487,86 +345,6 @@ }; name = Release; }; - 6C0990D42F2BE0C200D48100 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = DMX24B5FC3; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.sattlerjoshua.BetterCaptureTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = NO; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BetterCapture.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BetterCapture"; - }; - name = Debug; - }; - 6C0990D52F2BE0C200D48100 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = DMX24B5FC3; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.sattlerjoshua.BetterCaptureTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = NO; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BetterCapture.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BetterCapture"; - }; - name = Release; - }; - 6C0990D72F2BE0C200D48100 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = DMX24B5FC3; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.sattlerjoshua.BetterCaptureUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = NO; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; - TEST_TARGET_NAME = BetterCapture; - }; - name = Debug; - }; - 6C0990D82F2BE0C200D48100 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = DMX24B5FC3; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.sattlerjoshua.BetterCaptureUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = NO; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; - TEST_TARGET_NAME = BetterCapture; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -588,24 +366,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 6C0990D32F2BE0C200D48100 /* Build configuration list for PBXNativeTarget "BetterCaptureTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 6C0990D42F2BE0C200D48100 /* Debug */, - 6C0990D52F2BE0C200D48100 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 6C0990D62F2BE0C200D48100 /* Build configuration list for PBXNativeTarget "BetterCaptureUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 6C0990D72F2BE0C200D48100 /* Debug */, - 6C0990D82F2BE0C200D48100 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/BetterCapture.xcodeproj/xcshareddata/xcschemes/BetterCapture.xcscheme b/BetterCapture.xcodeproj/xcshareddata/xcschemes/BetterCapture.xcscheme new file mode 100644 index 0000000..e51a8b1 --- /dev/null +++ b/BetterCapture.xcodeproj/xcshareddata/xcschemes/BetterCapture.xcscheme @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BetterCapture/Model/SettingsStore.swift b/BetterCapture/Model/SettingsStore.swift index 323409f..ea2be66 100644 --- a/BetterCapture/Model/SettingsStore.swift +++ b/BetterCapture/Model/SettingsStore.swift @@ -56,6 +56,18 @@ enum VideoCodec: String, CaseIterable, Identifiable { return false } } + + /// Whether this codec supports user-adjustable quality/bitrate settings. + /// + /// ProRes codecs use fixed-quality encoding and ignore bitrate controls. + var supportsQualitySetting: Bool { + switch self { + case .h264, .hevc: + return true + case .proRes422, .proRes4444: + return false + } + } } /// Container format for output files @@ -128,6 +140,60 @@ enum FrameRate: Int, CaseIterable, Identifiable { return "\(rawValue) fps" } } + + /// The effective frame rate in Hz for encoding calculations (e.g. bitrate). + /// + /// For explicit rates this returns the selected value. For `.native` it + /// returns 60 as a practical upper bound. Although `CaptureEngine` sets a + /// minimum interval of 1/120s, ScreenCaptureKit only delivers frames when + /// content changes, so actual rates are typically well below 120. Using 60 + /// avoids inflating the bitrate budget beyond what the encoder will use. + var effectiveFrameRate: Double { + switch self { + case .native: 60.0 + default: Double(rawValue) + } + } +} + +/// Video quality presets controlling compression bitrate for H.264 and HEVC. +/// +/// Each preset defines a bits-per-pixel multiplier used to calculate the +/// target average bitrate: `width * height * bpp * frameRate`. +/// ProRes codecs ignore this setting since they use fixed-quality encoding. +enum VideoQuality: String, CaseIterable, Identifiable { + case low = "Low" + case medium = "Medium" + case high = "High" + + var id: String { rawValue } + + /// Bits-per-pixel multiplier for H.264 + var h264BitsPerPixel: Double { + switch self { + case .low: 0.04 + case .medium: 0.15 + case .high: 0.6 + } + } + + /// Bits-per-pixel multiplier for HEVC (more efficient codec) + var hevcBitsPerPixel: Double { + switch self { + case .low: 0.02 + case .medium: 0.1 + case .high: 0.4 + } + } + + /// Returns the bits-per-pixel multiplier for the given codec + func bitsPerPixel(for codec: VideoCodec) -> Double? { + switch codec { + case .h264: h264BitsPerPixel + case .hevc: hevcBitsPerPixel + case .proRes422, .proRes4444: nil + } + } } /// Persists user preferences using AppStorage @@ -146,6 +212,15 @@ final class SettingsStore { } } + var videoQuality: VideoQuality { + get { + VideoQuality(rawValue: videoQualityRaw) ?? .medium + } + set { + videoQualityRaw = newValue.rawValue + } + } + var videoCodec: VideoCodec { get { VideoCodec(rawValue: videoCodecRaw) ?? .hevc @@ -516,6 +591,18 @@ final class SettingsStore { } } + private var videoQualityRaw: String { + get { + access(keyPath: \.videoQualityRaw) + return UserDefaults.standard.string(forKey: "videoQuality") ?? VideoQuality.medium.rawValue + } + set { + withMutation(keyPath: \.videoQualityRaw) { + UserDefaults.standard.set(newValue, forKey: "videoQuality") + } + } + } + private var videoCodecRaw: String { get { access(keyPath: \.videoCodecRaw) diff --git a/BetterCapture/Service/AssetWriter.swift b/BetterCapture/Service/AssetWriter.swift index 65706a2..c563055 100644 --- a/BetterCapture/Service/AssetWriter.swift +++ b/BetterCapture/Service/AssetWriter.swift @@ -5,10 +5,10 @@ // Created by Joshua Sattler on 29.01.26. // -import Foundation import AVFoundation -import ScreenCaptureKit +import Foundation import OSLog +import ScreenCaptureKit import os /// Service responsible for writing captured media to disk using AVAssetWriter @@ -25,7 +25,8 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable private(set) var isWriting = false private(set) var outputURL: URL? - private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "BetterCapture", category: "AssetWriter") + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "BetterCapture", category: "AssetWriter") // Track if we've received the first sample private var hasStartedSession = false @@ -72,16 +73,19 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable if let videoInput, assetWriter.canAdd(videoInput) { assetWriter.add(videoInput) - // Create pixel buffer adaptor for appending raw pixel buffers from ScreenCaptureKit - // Use HDR 10-bit format when HDR is enabled with a compatible codec - let pixelFormat: OSType = (settings.captureHDR && settings.videoCodec.supportsHDR) + // Create pixel buffer adaptor for appending raw pixel buffers from ScreenCaptureKit. + // Must match the pixel format configured on SCStreamConfiguration in CaptureEngine. + // HDR capture uses 10-bit 4:2:0 because ScreenCaptureKit is optimized for this format + // when capturing HDR content. + let pixelFormat: OSType = + (settings.captureHDR && settings.videoCodec.supportsHDR) ? kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange : kCVPixelFormatType_32BGRA let sourcePixelBufferAttributes: [String: Any] = [ kCVPixelBufferPixelFormatTypeKey as String: pixelFormat, kCVPixelBufferWidthKey as String: Int(videoSize.width), - kCVPixelBufferHeightKey as String: Int(videoSize.height) + kCVPixelBufferHeightKey as String: Int(videoSize.height), ] pixelBufferAdaptor = AVAssetWriterInputPixelBufferAdaptor( assetWriterInput: videoInput, @@ -142,10 +146,13 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable /// Appends a video sample buffer - called synchronously from capture queue func appendVideoSample(_ sampleBuffer: CMSampleBuffer) { // Check frame status first - only process complete frames - guard let attachmentsArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[String: Any]], - let attachments = attachmentsArray.first, - let statusRawValue = attachments[SCStreamFrameInfo.status.rawValue] as? Int, - let status = SCFrameStatus(rawValue: statusRawValue) else { + guard + let attachmentsArray = CMSampleBufferGetSampleAttachmentsArray( + sampleBuffer, createIfNecessary: false) as? [[String: Any]], + let attachments = attachmentsArray.first, + let statusRawValue = attachments[SCStreamFrameInfo.status.rawValue] as? Int, + let status = SCFrameStatus(rawValue: statusRawValue) + else { logger.warning("Could not extract frame status from sample buffer") return } @@ -157,10 +164,11 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable lock.withLockUnchecked { guard let assetWriter, - assetWriter.status == .writing, - let videoInput, - videoInput.isReadyForMoreMediaData, - let adaptor = pixelBufferAdaptor else { + assetWriter.status == .writing, + let videoInput, + videoInput.isReadyForMoreMediaData, + let adaptor = pixelBufferAdaptor + else { return } @@ -176,7 +184,9 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable // Guard against non-monotonic timestamps. Presenter Overlay can // cause timing glitches when compositing the camera into the // stream; a single bad timestamp permanently fails the writer. - if lastVideoPresentationTime.isValid && presentationTime <= lastVideoPresentationTime { + if lastVideoPresentationTime.isValid + && presentationTime <= lastVideoPresentationTime + { return } } @@ -196,7 +206,8 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable } } else { if let error = assetWriter.error { - logger.error("Failed to append video pixel buffer: \(error.localizedDescription)") + logger.error( + "Failed to append video pixel buffer: \(error.localizedDescription)") } else { logger.error("Failed to append video pixel buffer - no error available") } @@ -208,9 +219,10 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable func appendAudioSample(_ sampleBuffer: CMSampleBuffer) { lock.withLockUnchecked { guard let assetWriter, - assetWriter.status == .writing, - let audioInput, - audioInput.isReadyForMoreMediaData else { + assetWriter.status == .writing, + let audioInput, + audioInput.isReadyForMoreMediaData + else { return } @@ -234,9 +246,10 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable func appendMicrophoneSample(_ sampleBuffer: CMSampleBuffer) { lock.withLockUnchecked { guard let assetWriter, - assetWriter.status == .writing, - let microphoneInput, - microphoneInput.isReadyForMoreMediaData else { + assetWriter.status == .writing, + let microphoneInput, + microphoneInput.isReadyForMoreMediaData + else { return } @@ -263,7 +276,9 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable throw AssetWriterError.noOutputURL } - logger.info("Finishing writing - status: \(assetWriter.status.rawValue), session started: \(self.hasStartedSession), frames written: \(self.frameCount)") + logger.info( + "Finishing writing - status: \(assetWriter.status.rawValue), session started: \(self.hasStartedSession), frames written: \(self.frameCount)" + ) // Check if we actually started a session (received at least one frame) guard hasStartedSession else { @@ -295,7 +310,8 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable if assetWriter.status == .failed { let error = assetWriter.error - logger.error("AssetWriter failed: \(error?.localizedDescription ?? "unknown error")") + logger.error( + "AssetWriter failed: \(error?.localizedDescription ?? "unknown error")") throw AssetWriterError.writingFailed(error) } @@ -303,7 +319,9 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable hasStartedSession = false lastVideoPresentationTime = .invalid - logger.info("AssetWriter finished writing \(self.frameCount) frames to: \(url.lastPathComponent)") + logger.info( + "AssetWriter finished writing \(self.frameCount) frames to: \(url.lastPathComponent)" + ) frameCount = 0 // Clean up @@ -347,7 +365,7 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable private func createVideoSettings(from settings: SettingsStore, size: CGSize) -> [String: Any] { var videoSettings: [String: Any] = [ AVVideoWidthKey: Int(size.width), - AVVideoHeightKey: Int(size.height) + AVVideoHeightKey: Int(size.height), ] switch settings.videoCodec { @@ -368,12 +386,42 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable videoSettings[AVVideoCodecKey] = AVVideoCodecType.proRes4444 } - // Add HDR color space settings for ProRes codecs with HDR enabled + // Add compression properties for H.264 and HEVC to control bitrate. + // ProRes codecs use fixed-quality encoding and don't need these. + if let bpp = settings.videoQuality.bitsPerPixel(for: settings.videoCodec) { + let frameRate = settings.frameRate.effectiveFrameRate + let bitrate = Int(size.width * size.height * bpp * frameRate) + + videoSettings[AVVideoCompressionPropertiesKey] = [ + AVVideoAverageBitRateKey: bitrate, + AVVideoExpectedSourceFrameRateKey: frameRate, + AVVideoMaxKeyFrameIntervalKey: Int(frameRate * 2), + ] + + logger.info( + "Video compression: \(bitrate / 1_000_000) Mbps at \(Int(frameRate)) fps (\(settings.videoQuality.rawValue) quality)" + ) + } + + // Tag the container with color space metadata. + // For HDR: ScreenCaptureKit with captureDynamicRange = .hdrCanonicalDisplay and + // colorSpaceName = CGColorSpace.itur_2100_HLG delivers pixel buffers with + // correct BT.2020 / HLG data. + // For SDR: We explicitly tag the video with Rec. 709 metadata. + // Setting AVVideoColorPropertiesKey ensures the file's 'colr' atom and H.264/HEVC + // VUI parameters are written. Without this, players and tools like ffprobe + // will report the color range and color space as "unknown". if settings.captureHDR && settings.videoCodec.supportsHDR { videoSettings[AVVideoColorPropertiesKey] = [ AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_2020, AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_2100_HLG, - AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_2020 + AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_2020, + ] + } else { + videoSettings[AVVideoColorPropertiesKey] = [ + AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2, + AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2, + AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2, ] } @@ -387,7 +435,7 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 48000, AVNumberOfChannelsKey: 2, - AVEncoderBitRateKey: 256000 + AVEncoderBitRateKey: 256000, ] case .pcm: @@ -398,7 +446,7 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable AVLinearPCMBitDepthKey: 16, AVLinearPCMIsFloatKey: false, AVLinearPCMIsBigEndianKey: false, - AVLinearPCMIsNonInterleaved: false + AVLinearPCMIsNonInterleaved: false, ] } } @@ -408,15 +456,21 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable extension AssetWriter { - func captureEngine(_ engine: CaptureEngine, didOutputVideoSampleBuffer sampleBuffer: CMSampleBuffer) { + func captureEngine( + _ engine: CaptureEngine, didOutputVideoSampleBuffer sampleBuffer: CMSampleBuffer + ) { appendVideoSample(sampleBuffer) } - func captureEngine(_ engine: CaptureEngine, didOutputAudioSampleBuffer sampleBuffer: CMSampleBuffer) { + func captureEngine( + _ engine: CaptureEngine, didOutputAudioSampleBuffer sampleBuffer: CMSampleBuffer + ) { appendAudioSample(sampleBuffer) } - func captureEngine(_ engine: CaptureEngine, didOutputMicrophoneSampleBuffer sampleBuffer: CMSampleBuffer) { + func captureEngine( + _ engine: CaptureEngine, didOutputMicrophoneSampleBuffer sampleBuffer: CMSampleBuffer + ) { appendMicrophoneSample(sampleBuffer) } } diff --git a/BetterCapture/Service/CaptureEngine.swift b/BetterCapture/Service/CaptureEngine.swift index dbfb5dc..35b77f6 100644 --- a/BetterCapture/Service/CaptureEngine.swift +++ b/BetterCapture/Service/CaptureEngine.swift @@ -228,14 +228,14 @@ final class CaptureEngine: NSObject { if settings.presenterOverlayEnabled { config.presenterOverlayPrivacyAlertSetting = .always } - - // Configure pixel format and dynamic range based on HDR setting if settings.captureHDR && settings.videoCodec.supportsHDR { - // HDR: Use 10-bit YCbCr format with HDR dynamic range + // Use 10-bit 4:2:0 YCbCr for HDR. ScreenCaptureKit is optimized for this format. config.pixelFormat = kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange - config.captureDynamicRange = .hdrLocalDisplay - } else { - // SDR: Use 8-bit BGRA format + // Use canonical display to optimize HDR content for playback on other devices, + // and explicitly request the HLG color space to match AVAssetWriter tags. + config.captureDynamicRange = .hdrCanonicalDisplay + config.colorSpaceName = CGColorSpace.itur_2100_HLG + } else { // SDR: Use 8-bit BGRA format config.pixelFormat = kCVPixelFormatType_32BGRA config.captureDynamicRange = .SDR } diff --git a/BetterCapture/View/SettingsView.swift b/BetterCapture/View/SettingsView.swift index 9700907..422b869 100644 --- a/BetterCapture/View/SettingsView.swift +++ b/BetterCapture/View/SettingsView.swift @@ -55,6 +55,14 @@ struct VideoSettingsView: View { } } + private var qualityHelpText: String { + if settings.videoCodec.supportsQualitySetting { + return "Controls the video bitrate. Higher quality produces sharper output with larger files" + } else { + return "ProRes codecs use fixed-quality encoding" + } + } + var body: some View { Form { Section("Recording") { @@ -82,6 +90,14 @@ struct VideoSettingsView: View { Text(".\(format.rawValue)").tag(format) } } + + Picker("Quality", selection: $settings.videoQuality) { + ForEach(VideoQuality.allCases) { quality in + Text(quality.rawValue).tag(quality) + } + } + .disabled(!settings.videoCodec.supportsQualitySetting) + .help(qualityHelpText) } Section("Advanced") { diff --git a/BetterCaptureTests/BetterCaptureTests.swift b/BetterCaptureTests/BetterCaptureTests.swift deleted file mode 100644 index 827c545..0000000 --- a/BetterCaptureTests/BetterCaptureTests.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// BetterCaptureTests.swift -// BetterCaptureTests -// -// Created by Joshua Sattler on 29.01.26. -// - -import Testing -@testable import BetterCapture - -struct BetterCaptureTests { - - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - } - -} diff --git a/BetterCaptureUITests/BetterCaptureUITests.swift b/BetterCaptureUITests/BetterCaptureUITests.swift deleted file mode 100644 index 1c6e2f7..0000000 --- a/BetterCaptureUITests/BetterCaptureUITests.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// BetterCaptureUITests.swift -// BetterCaptureUITests -// -// Created by Joshua Sattler on 29.01.26. -// - -import XCTest - -final class BetterCaptureUITests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - @MainActor - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() - - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - @MainActor - func testLaunchPerformance() throws { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } - } -} diff --git a/BetterCaptureUITests/BetterCaptureUITestsLaunchTests.swift b/BetterCaptureUITests/BetterCaptureUITestsLaunchTests.swift deleted file mode 100644 index f7d87af..0000000 --- a/BetterCaptureUITests/BetterCaptureUITestsLaunchTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// BetterCaptureUITestsLaunchTests.swift -// BetterCaptureUITests -// -// Created by Joshua Sattler on 29.01.26. -// - -import XCTest - -final class BetterCaptureUITestsLaunchTests: XCTestCase { - - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - - override func setUpWithError() throws { - continueAfterFailure = false - } - - @MainActor - func testLaunch() throws { - let app = XCUIApplication() - app.launch() - - // Insert steps here to perform after app launch but before taking a screenshot, - // such as logging into a test account or navigating somewhere in the app - - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" - attachment.lifetime = .keepAlways - add(attachment) - } -}