Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions BetterCapture/Model/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions BetterCapture/Service/AssetWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
Expand Down
16 changes: 16 additions & 0 deletions BetterCapture/View/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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") {
Expand Down
Loading