diff --git a/BetterCapture/Model/SettingsStore.swift b/BetterCapture/Model/SettingsStore.swift index 323409f..a045b35 100644 --- a/BetterCapture/Model/SettingsStore.swift +++ b/BetterCapture/Model/SettingsStore.swift @@ -130,6 +130,46 @@ enum FrameRate: Int, CaseIterable, Identifiable { } } +/// 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.05 + case .medium: 0.1 + case .high: 0.2 + } + } + + /// Bits-per-pixel multiplier for HEVC (more efficient codec) + var hevcBitsPerPixel: Double { + switch self { + case .low: 0.03 + case .medium: 0.06 + case .high: 0.1 + } + } + + /// 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 @MainActor @Observable @@ -146,6 +186,15 @@ final class SettingsStore { } } + var videoQuality: VideoQuality { + get { + VideoQuality(rawValue: videoQualityRaw) ?? .high + } + set { + videoQualityRaw = newValue.rawValue + } + } + var videoCodec: VideoCodec { get { VideoCodec(rawValue: videoCodecRaw) ?? .hevc @@ -516,6 +565,18 @@ final class SettingsStore { } } + private var videoQualityRaw: String { + get { + access(keyPath: \.videoQualityRaw) + return UserDefaults.standard.string(forKey: "videoQuality") ?? VideoQuality.high.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..09b49f0 100644 --- a/BetterCapture/Service/AssetWriter.swift +++ b/BetterCapture/Service/AssetWriter.swift @@ -368,6 +368,21 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable videoSettings[AVVideoCodecKey] = AVVideoCodecType.proRes4444 } + // 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 == .native ? 60.0 : Double(settings.frameRate.rawValue) + 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)") + } + // Add HDR color space settings for ProRes codecs with HDR enabled if settings.captureHDR && settings.videoCodec.supportsHDR { videoSettings[AVVideoColorPropertiesKey] = [ diff --git a/BetterCapture/View/SettingsView.swift b/BetterCapture/View/SettingsView.swift index 9700907..2798331 100644 --- a/BetterCapture/View/SettingsView.swift +++ b/BetterCapture/View/SettingsView.swift @@ -55,6 +55,14 @@ struct VideoSettingsView: View { } } + private var qualityHelpText: String { + if settings.videoQuality.bitsPerPixel(for: settings.videoCodec) != nil { + 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.videoQuality.bitsPerPixel(for: settings.videoCodec) == nil) + .help(qualityHelpText) } Section("Advanced") {