Skip to content
Merged
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
76 changes: 73 additions & 3 deletions BetterCapture/Model/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,32 @@
/// Whether this codec supports HDR (10-bit) recording
var supportsHDR: Bool {
switch self {
case .proRes422, .proRes4444:
case .hevc, .proRes422, .proRes4444:
return true
case .h264, .hevc:
case .h264:
return false
}
}

/// The pixel format ScreenCaptureKit and AVAssetWriter should use for HDR capture.
///
/// Each codec requires a specific chroma subsampling and bit depth:
/// - HEVC Main 10: 10-bit 4:2:0
/// - ProRes 422: 10-bit 4:2:2
/// - ProRes 4444: 16-bit half-float RGBA
var hdrPixelFormat: OSType {
switch self {
case .hevc:
return kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange
case .proRes422:
return kCVPixelFormatType_422YpCbCr10BiPlanarVideoRange
case .proRes4444:
return kCVPixelFormatType_64RGBAHalf
case .h264:
return kCVPixelFormatType_32BGRA
}
}

/// Whether this codec supports user-adjustable quality/bitrate settings.
///
/// ProRes codecs use fixed-quality encoding and ignore bitrate controls.
Expand All @@ -72,8 +91,8 @@

/// Container format for output files
enum ContainerFormat: String, CaseIterable, Identifiable {
case mov = "mov"

Check warning on line 94 in BetterCapture/Model/SettingsStore.swift

View workflow job for this annotation

GitHub Actions / Lint

String enum values can be omitted when they are equal to the enumcase name (redundant_string_enum_value)
case mp4 = "mp4"

Check warning on line 95 in BetterCapture/Model/SettingsStore.swift

View workflow job for this annotation

GitHub Actions / Lint

String enum values can be omitted when they are equal to the enumcase name (redundant_string_enum_value)

var id: String { rawValue }

Expand Down Expand Up @@ -196,10 +215,29 @@
}
}

/// Describes which ScreenCaptureKit HDR configuration is active, so the
/// ``AssetWriter`` can tag the output container with matching colorimetry.
enum HDRPreset {
/// SDR capture — no HDR color properties needed.
case sdr

/// Manual BT.2020 / PQ configuration applied to a plain
/// `SCStreamConfiguration`. Used on macOS 15–25 to produce
/// HDR10-compatible output (BT.2020 primaries, PQ transfer
/// function, BT.2020 YCbCr matrix).
case hdr10Manual

/// ``SCStreamConfiguration.Preset.captureHDRRecordingPreservedSDRHDR10``
/// (macOS 26+). Same BT.2020 / PQ colorimetry as `.hdr10Manual`, but
/// also injects static HDR10 mastering metadata and preserves SDR UI
/// appearance on HDR screens.
case hdr10PreservedSDR
}

/// Persists user preferences using AppStorage
@MainActor
@Observable
final class SettingsStore {

Check warning on line 240 in BetterCapture/Model/SettingsStore.swift

View workflow job for this annotation

GitHub Actions / Lint

Class body should span 300 lines or less excluding comments and whitespace: currently spans 380 lines (type_body_length)

// MARK: - Video Settings

Expand Down Expand Up @@ -250,6 +288,12 @@
if !newValue.supportsHDR {
captureHDR = false
}

// HEVC with alpha uses a separate codec type that doesn't support
// Main 10 HDR, so alpha and HDR are mutually exclusive for HEVC.
if newValue == .hevc && captureHDR {
captureAlphaChannel = false
}
}
}

Expand Down Expand Up @@ -289,12 +333,21 @@
if !videoCodec.supportsAlphaChannel || !containerFormat.supportsAlphaChannel {
return false
}
// HEVC with alpha uses a different codec type incompatible with Main 10 HDR
if videoCodec == .hevc && captureHDR {
return false
}
return UserDefaults.standard.bool(forKey: "captureAlphaChannel")
}
set {
// Only allow alpha channel if both codec and container support it
let canEnable = videoCodec.supportsAlphaChannel && containerFormat.supportsAlphaChannel
let finalValue = newValue && canEnable
var finalValue = newValue && canEnable

// HEVC alpha and HDR are mutually exclusive
if videoCodec == .hevc && finalValue && captureHDR {
finalValue = false
}

withMutation(keyPath: \.captureAlphaChannel) {
UserDefaults.standard.set(finalValue, forKey: "captureAlphaChannel")
Expand All @@ -311,9 +364,26 @@
withMutation(keyPath: \.captureHDR) {
UserDefaults.standard.set(newValue, forKey: "captureHDR")
}

// HEVC alpha and HDR are mutually exclusive
if newValue && videoCodec == .hevc {
captureAlphaChannel = false
}
}
}

/// The active HDR preset for the current codec and OS version.
///
/// Both ``CaptureEngine`` and ``AssetWriter`` use this to ensure the
/// stream configuration and output color tags stay in sync.
var hdrPreset: HDRPreset {
guard captureHDR && videoCodec.supportsHDR else { return .sdr }
if #available(macOS 26, *) {
return .hdr10PreservedSDR
}
return .hdr10Manual
}

// MARK: - Audio Settings

var captureMicrophone: Bool {
Expand Down Expand Up @@ -653,4 +723,4 @@
func generateOutputURL() -> URL {
outputDirectory.appending(path: generateFilename())
}
}

Check warning on line 726 in BetterCapture/Model/SettingsStore.swift

View workflow job for this annotation

GitHub Actions / Lint

File should contain 500 lines or less: currently contains 726 (file_length)
117 changes: 103 additions & 14 deletions BetterCapture/Service/AssetWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
//

import AVFoundation
import CoreVideo
import Foundation
import OSLog
import ScreenCaptureKit
import VideoToolbox
import os

/// Service responsible for writing captured media to disk using AVAssetWriter
Expand Down Expand Up @@ -37,6 +39,14 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable
/// occur when Presenter Overlay composites the camera into the stream.
private var lastVideoPresentationTime: CMTime = .invalid

/// The active HDR preset for this recording session, used to select the
/// correct color properties for the output container and per-frame tagging.
private var activeHDRPreset: HDRPreset = .sdr

/// Whether per-frame `CVBufferSetAttachment` color tagging is needed.
/// True only for ProRes HDR, where `AVVideoColorPropertiesKey` must be omitted.
private var tagBuffersWithHDRColorimetry = false

// Lock for thread-safe access to writer state
private let lock = OSAllocatedUnfairLock()

Expand Down Expand Up @@ -75,11 +85,9 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable

// 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
? settings.videoCodec.hdrPixelFormat
: kCVPixelFormatType_32BGRA

let sourcePixelBufferAttributes: [String: Any] = [
Expand Down Expand Up @@ -115,6 +123,11 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable
}
}

activeHDRPreset = settings.hdrPreset
let isProResHDR = activeHDRPreset != .sdr
&& (settings.videoCodec == .proRes422 || settings.videoCodec == .proRes4444)
tagBuffersWithHDRColorimetry = isProResHDR

outputURL = url
hasStartedSession = false
sessionStartTime = .zero
Expand Down Expand Up @@ -197,6 +210,21 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable
return
}

// Log incoming buffer properties on the first frame to aid HDR debugging.
if frameCount == 0 {
logPixelBufferProperties(pixelBuffer)
}

// For ProRes HDR, inject BT.2020 / PQ colorimetry directly onto
// the pixel buffer. AVAssetWriter prohibits AVVideoColorPropertiesKey
// for the high-bit-depth formats ProRes uses, so we tag each frame
// to ensure the output file contains correct 'colr' / 'nclx' atoms.
if tagBuffersWithHDRColorimetry {
CVBufferSetAttachment(pixelBuffer, kCVImageBufferColorPrimariesKey, kCVImageBufferColorPrimaries_ITU_R_2020, .shouldPropagate)
CVBufferSetAttachment(pixelBuffer, kCVImageBufferTransferFunctionKey, kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ, .shouldPropagate)
CVBufferSetAttachment(pixelBuffer, kCVImageBufferYCbCrMatrixKey, kCVImageBufferYCbCrMatrix_ITU_R_2020, .shouldPropagate)
}

// Append using the pixel buffer adaptor
if adaptor.append(pixelBuffer, withPresentationTime: presentationTime) {
lastVideoPresentationTime = presentationTime
Expand Down Expand Up @@ -318,6 +346,8 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable
isWriting = false
hasStartedSession = false
lastVideoPresentationTime = .invalid
activeHDRPreset = .sdr
tagBuffersWithHDRColorimetry = false

logger.info(
"AssetWriter finished writing \(self.frameCount) frames to: \(url.lastPathComponent)"
Expand All @@ -342,6 +372,8 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable
isWriting = false
hasStartedSession = false
lastVideoPresentationTime = .invalid
activeHDRPreset = .sdr
tagBuffersWithHDRColorimetry = false
frameCount = 0

// Clean up temp file if it exists
Expand All @@ -368,6 +400,8 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable
AVVideoHeightKey: Int(size.height),
]

let hdrPreset = settings.hdrPreset

switch settings.videoCodec {
case .h264:
videoSettings[AVVideoCodecKey] = AVVideoCodecType.h264
Expand All @@ -392,29 +426,48 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable
let frameRate = settings.frameRate.effectiveFrameRate
let bitrate = Int(size.width * size.height * bpp * frameRate)

videoSettings[AVVideoCompressionPropertiesKey] = [
var compressionProperties: [String: Any] = [
AVVideoAverageBitRateKey: bitrate,
AVVideoExpectedSourceFrameRateKey: frameRate,
AVVideoMaxKeyFrameIntervalKey: Int(frameRate * 2),
]

// HEVC HDR: enforce Main 10 profile to prevent 8-bit fallback and
// enable automatic HDR metadata insertion (HDR10 / Dolby Vision).
if settings.videoCodec == .hevc && hdrPreset != .sdr {
compressionProperties[AVVideoProfileLevelKey] =
kVTProfileLevel_HEVC_Main10_AutoLevel as String
compressionProperties[kVTCompressionPropertyKey_HDRMetadataInsertionMode as String] =
kVTHDRMetadataInsertionMode_Auto as String
}

videoSettings[AVVideoCompressionPropertiesKey] = compressionProperties

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 {
// Color space tagging strategy differs by codec:
//
// HEVC HDR: Tag via AVVideoColorPropertiesKey with BT.2020 / PQ.
// The encoder writes the correct 'colr' atom and VUI parameters.
//
// ProRes HDR: Do NOT set AVVideoColorPropertiesKey. AVAssetWriter
// prohibits automatic color matching for the high-bit-depth pixel
// formats ProRes uses. Instead, BT.2020 / PQ colorimetry is
// injected per-frame via CVBufferSetAttachment in appendVideoSample().
//
// SDR (all codecs): Tag with Rec. 709 to ensure 'colr' atoms and
// VUI parameters are written.
let isProRes = settings.videoCodec == .proRes422 || settings.videoCodec == .proRes4444

if isProRes && hdrPreset != .sdr {
// Color properties are tagged per-frame via CVBufferSetAttachment.
} else if hdrPreset != .sdr {
videoSettings[AVVideoColorPropertiesKey] = [
AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_2020,
AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_2100_HLG,
AVVideoTransferFunctionKey: AVVideoTransferFunction_SMPTE_ST_2084_PQ,
AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_2020,
]
} else {
Expand All @@ -428,6 +481,42 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable
return videoSettings
}

/// Logs the pixel format, color space, and matrix of an incoming pixel buffer
/// to help diagnose HDR color mismatches.
private func logPixelBufferProperties(_ pixelBuffer: CVPixelBuffer) {
let pixelFormat = CVPixelBufferGetPixelFormatType(pixelBuffer)
let fourCC = String(format: "%c%c%c%c",
(pixelFormat >> 24) & 0xFF,
(pixelFormat >> 16) & 0xFF,
(pixelFormat >> 8) & 0xFF,
pixelFormat & 0xFF)

let primaries = CVBufferCopyAttachment(pixelBuffer, kCVImageBufferColorPrimariesKey, nil)
as? String ?? "none"
let transfer = CVBufferCopyAttachment(pixelBuffer, kCVImageBufferTransferFunctionKey, nil)
as? String ?? "none"
let matrix = CVBufferCopyAttachment(pixelBuffer, kCVImageBufferYCbCrMatrixKey, nil)
as? String ?? "none"

let colorSpaceName: String
if let cgColorSpace = CVImageBufferGetColorSpace(pixelBuffer)?.takeUnretainedValue() {
colorSpaceName = cgColorSpace.name as String? ?? "unnamed"
} else {
colorSpaceName = "nil"
}

logger.info(
"""
First frame buffer properties — \
pixelFormat: \(fourCC) (0x\(String(pixelFormat, radix: 16))), \
colorPrimaries: \(primaries), \
transferFunction: \(transfer), \
yCbCrMatrix: \(matrix), \
CGColorSpace: \(colorSpaceName)
"""
)
}

private func createAudioSettings(from settings: SettingsStore) -> [String: Any] {
switch settings.audioCodec {
case .aac:
Expand Down
42 changes: 30 additions & 12 deletions BetterCapture/Service/CaptureEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,36 @@ final class CaptureEngine: NSObject {
/// - contentSize: The output dimensions for the captured video
/// - sourceRect: Optional rectangle for area selection (display points, top-left origin)
private func createStreamConfiguration(from settings: SettingsStore, contentSize: CGSize, sourceRect: CGRect? = nil) -> SCStreamConfiguration {
let config = SCStreamConfiguration()
let config: SCStreamConfiguration

switch settings.hdrPreset {
case .hdr10PreservedSDR:
if #available(macOS 26, *) {
config = SCStreamConfiguration(preset: .captureHDRRecordingPreservedSDRHDR10)
} else {
// Unreachable — hdrPreset only returns .hdr10PreservedSDR on macOS 26+
config = SCStreamConfiguration()
}
// Only override pixel format for ProRes, which needs different
// chroma subsampling (4:2:2 / 16-bit RGBA). For HEVC, let the
// preset's own pixel format stand to preserve EDR headroom.
if settings.videoCodec == .proRes422 || settings.videoCodec == .proRes4444 {
config.pixelFormat = settings.videoCodec.hdrPixelFormat
}

case .hdr10Manual:
// Manually replicate the HDR10 recording preset for pre-macOS 26.
// Do NOT set colorSpaceName — it triggers an internal CoreGraphics
// tone-mapping pass that destructively clips EDR headroom.
config = SCStreamConfiguration()
config.captureDynamicRange = .hdrCanonicalDisplay
config.pixelFormat = settings.videoCodec.hdrPixelFormat

case .sdr:
config = SCStreamConfiguration()
config.pixelFormat = kCVPixelFormatType_32BGRA
config.captureDynamicRange = .SDR
}

// Set output dimensions - required for proper capture
config.width = Int(contentSize.width)
Expand Down Expand Up @@ -228,17 +257,6 @@ final class CaptureEngine: NSObject {
if settings.presenterOverlayEnabled {
config.presenterOverlayPrivacyAlertSetting = .always
}
if settings.captureHDR && settings.videoCodec.supportsHDR {
// Use 10-bit 4:2:0 YCbCr for HDR. ScreenCaptureKit is optimized for this format.
config.pixelFormat = kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange
// 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
}

return config
}
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ How Apple's ScreenCaptureKit and AVFoundation frameworks work together for scree

- [Overview:](screen-capture-kit/OVERVIEW.md) Key classes (`SCStream`, `SCStreamConfiguration`, `AVAssetWriter`, etc.), class relationships, and the capture-to-disk pipeline.
- [Configuration Reference:](screen-capture-kit/CONFIGURATION.md) How `SCStreamConfiguration` properties map to video/audio concepts. How SCK and AVFoundation settings must align.
- [HDR Capture Reference:](screen-capture-kit/HDR.md) ScreenCaptureKit HDR configuration, presets, encoding constraints, pitfalls, and verification.

## Architecture

Expand All @@ -24,7 +25,6 @@ How BetterCapture is built. Component structure, data flow, and implementation d

## Specifications

- [App Specification:](SPEC.md) UI design, functional requirements, configuration defaults.
- [Feature Specs:](specs/) Detailed specifications for individual features.

## Process
Expand Down
Loading
Loading