From 8c6eeec020a71315b7b068a6b9aa631dc50a6d5a Mon Sep 17 00:00:00 2001 From: Joshua Sattler <34030048+jsattler@users.noreply.github.com> Date: Sun, 8 Feb 2026 00:19:39 +0100 Subject: [PATCH] fix(settings): enforce MP4 container codec compatibility MP4 recording was failing because incompatible codecs (ProRes, HEVC with alpha) were allowed. This adds container-aware codec restrictions: - Add supportedVideoCodecs and supportedAudioCodecs to ContainerFormat - Auto-switch to compatible codec when changing container format - Show incompatible codecs as disabled with explanation message - Disable alpha channel when container doesn't support it - ProRes 4444 now correctly shows alpha toggle as ON (always has alpha) - Add docs/compatibility.md documenting codec/container matrix Fixes #31 --- BetterCapture/Model/SettingsStore.swift | 92 +++++++++++++++++-- BetterCapture/View/MenuBarSettingsView.swift | 74 ++++++++++++--- BetterCapture/View/SettingsView.swift | 22 ++++- docs/compatibility.md | 95 ++++++++++++++++++++ 4 files changed, 261 insertions(+), 22 deletions(-) create mode 100644 docs/compatibility.md diff --git a/BetterCapture/Model/SettingsStore.swift b/BetterCapture/Model/SettingsStore.swift index cad4669..108562b 100644 --- a/BetterCapture/Model/SettingsStore.swift +++ b/BetterCapture/Model/SettingsStore.swift @@ -66,6 +66,41 @@ enum ContainerFormat: String, CaseIterable, Identifiable { var id: String { rawValue } var fileExtension: String { rawValue } + + /// Video codecs supported by this container format + var supportedVideoCodecs: [VideoCodec] { + switch self { + case .mov: + // MOV (QuickTime) supports all codecs including ProRes and HEVC with alpha + return VideoCodec.allCases + case .mp4: + // MP4 (MPEG-4) only supports H.264 and HEVC (without alpha) + return [.h264, .hevc] + } + } + + /// Whether this container supports alpha channel video + var supportsAlphaChannel: Bool { + switch self { + case .mov: + return true + case .mp4: + // MP4 does not support alpha channel (HEVC with alpha or ProRes 4444) + return false + } + } + + /// Audio codecs supported by this container format + var supportedAudioCodecs: [AudioCodec] { + switch self { + case .mov: + // MOV supports all audio codecs + return AudioCodec.allCases + case .mp4: + // MP4 only supports AAC (not raw PCM) + return [.aac] + } + } } /// Audio codec options @@ -116,16 +151,25 @@ final class SettingsStore { VideoCodec(rawValue: videoCodecRaw) ?? .hevc } set { + // Ensure the codec is compatible with the current container format + guard containerFormat.supportedVideoCodecs.contains(newValue) else { + // If codec is not compatible, switch to MOV container first + containerFormatRaw = ContainerFormat.mov.rawValue + videoCodecRaw = newValue.rawValue + return + } + videoCodecRaw = newValue.rawValue - // Set alpha channel based on codec capabilities + + // Set alpha channel based on codec and container capabilities if newValue.alwaysHasAlpha { - // ProRes 4444 always has alpha + // ProRes 4444 always has alpha, requires MOV container captureAlphaChannel = true - } else if !newValue.supportsAlphaChannel { - // H.264 and ProRes 422 never have alpha + } else if !newValue.supportsAlphaChannel || !containerFormat.supportsAlphaChannel { + // H.264, ProRes 422 never have alpha, or container doesn't support it captureAlphaChannel = false } - // HEVC can toggle alpha, so leave it as-is + // HEVC can toggle alpha (if container supports it), so leave it as-is // Disable HDR for codecs that don't support it if !newValue.supportsHDR { @@ -140,17 +184,45 @@ final class SettingsStore { } set { containerFormatRaw = newValue.rawValue + + // Ensure current video codec is compatible with new container + if !newValue.supportedVideoCodecs.contains(videoCodec) { + // Switch to a compatible codec (prefer HEVC for quality) + videoCodec = .hevc + } + + // Disable alpha channel if container doesn't support it + if !newValue.supportsAlphaChannel { + captureAlphaChannel = false + } + + // Ensure current audio codec is compatible with new container + if !newValue.supportedAudioCodecs.contains(audioCodec) { + audioCodec = .aac + } } } var captureAlphaChannel: Bool { get { access(keyPath: \.captureAlphaChannel) + // ProRes 4444 always has alpha regardless of stored value + if videoCodec.alwaysHasAlpha { + return true + } + // If codec or container doesn't support alpha, always return false + if !videoCodec.supportsAlphaChannel || !containerFormat.supportsAlphaChannel { + 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 + withMutation(keyPath: \.captureAlphaChannel) { - UserDefaults.standard.set(newValue, forKey: "captureAlphaChannel") + UserDefaults.standard.set(finalValue, forKey: "captureAlphaChannel") } } } @@ -198,6 +270,14 @@ final class SettingsStore { AudioCodec(rawValue: audioCodecRaw) ?? .aac } set { + // Ensure the audio codec is compatible with the current container format + guard containerFormat.supportedAudioCodecs.contains(newValue) else { + // If codec is not compatible, switch to MOV container first + containerFormatRaw = ContainerFormat.mov.rawValue + audioCodecRaw = newValue.rawValue + return + } + audioCodecRaw = newValue.rawValue } } diff --git a/BetterCapture/View/MenuBarSettingsView.swift b/BetterCapture/View/MenuBarSettingsView.swift index 9cd6733..9904473 100644 --- a/BetterCapture/View/MenuBarSettingsView.swift +++ b/BetterCapture/View/MenuBarSettingsView.swift @@ -86,10 +86,32 @@ struct MenuBarToggle: View { struct MenuBarExpandablePicker: View { let name: String @Binding var selection: SelectionValue - let options: [(value: SelectionValue, label: String)] + let options: [(value: SelectionValue, label: String, isDisabled: Bool, disabledMessage: String?)] @State private var isExpanded = false @State private var isHovered = false + /// Convenience initializer for simple options without disabled state + init( + name: String, + selection: Binding, + options: [(value: SelectionValue, label: String)] + ) { + self.name = name + self._selection = selection + self.options = options.map { ($0.value, $0.label, false, nil) } + } + + /// Full initializer with disabled state support + init( + name: String, + selection: Binding, + optionsWithState: [(value: SelectionValue, label: String, isDisabled: Bool, disabledMessage: String?)] + ) { + self.name = name + self._selection = selection + self.options = optionsWithState + } + var body: some View { VStack(spacing: 0) { // Header row @@ -131,7 +153,9 @@ struct MenuBarExpandablePicker: View { ForEach(options, id: \.value) { option in PickerOptionRow( label: option.label, - isSelected: selection == option.value + isSelected: selection == option.value, + isDisabled: option.isDisabled, + disabledMessage: option.disabledMessage ) { selection = option.value withAnimation(.easeInOut(duration: 0.2)) { @@ -157,15 +181,24 @@ struct MenuBarExpandablePicker: View { struct PickerOptionRow: View { let label: String let isSelected: Bool + var isDisabled: Bool = false + var disabledMessage: String? = nil let onSelect: () -> Void @State private var isHovered = false var body: some View { Button(action: onSelect) { HStack { - Text(label) - .font(.system(size: 13)) - .foregroundStyle(.primary) + VStack(alignment: .leading, spacing: 1) { + Text(label) + .font(.system(size: 13)) + .foregroundStyle(isDisabled ? .tertiary : .primary) + if isDisabled, let message = disabledMessage { + Text(message) + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + } Spacer() if isSelected { Image(systemName: "checkmark") @@ -178,9 +211,10 @@ struct PickerOptionRow: View { .contentShape(.rect) } .buttonStyle(.plain) + .disabled(isDisabled) .background( RoundedRectangle(cornerRadius: 4) - .fill(isHovered ? .gray.opacity(0.1) : .clear) + .fill(isHovered && !isDisabled ? .gray.opacity(0.1) : .clear) .padding(.horizontal, 4) ) .onHover { hovering in @@ -410,11 +444,19 @@ struct VideoSettingsSection: View { options: FrameRate.allCases.map { ($0, $0.displayName) } ) - // Video Codec Picker + // Video Codec Picker (shows all codecs, disables incompatible ones) MenuBarExpandablePicker( name: "Codec", selection: $settings.videoCodec, - options: VideoCodec.allCases.map { ($0, $0.rawValue) } + optionsWithState: VideoCodec.allCases.map { codec in + let isSupported = settings.containerFormat.supportedVideoCodecs.contains(codec) + return ( + value: codec, + label: codec.rawValue, + isDisabled: !isSupported, + disabledMessage: isSupported ? nil : "Not supported for \(settings.containerFormat.rawValue.uppercased())" + ) + } ) // Container Format Picker @@ -424,11 +466,11 @@ struct VideoSettingsSection: View { options: ContainerFormat.allCases.map { ($0, $0.rawValue.uppercased()) } ) - // Alpha Channel Toggle (always visible, but disabled for non-toggleable codecs) + // Alpha Channel Toggle (disabled if codec doesn't support or container doesn't support) MenuBarToggle( name: "Capture Alpha Channel", isOn: $settings.captureAlphaChannel, - isDisabled: !settings.videoCodec.canToggleAlpha + isDisabled: !settings.videoCodec.canToggleAlpha || !settings.containerFormat.supportsAlphaChannel ) // HDR Recording Toggle (disabled for codecs that don't support HDR) @@ -469,11 +511,19 @@ struct AudioSettingsSection: View { ) } - // Audio Codec Picker + // Audio Codec Picker (shows all codecs, disables incompatible ones) MenuBarExpandablePicker( name: "Audio Codec", selection: $settings.audioCodec, - options: AudioCodec.allCases.map { ($0, $0.rawValue) } + optionsWithState: AudioCodec.allCases.map { codec in + let isSupported = settings.containerFormat.supportedAudioCodecs.contains(codec) + return ( + value: codec, + label: codec.rawValue, + isDisabled: !isSupported, + disabledMessage: isSupported ? nil : "Not supported for \(settings.containerFormat.rawValue.uppercased())" + ) + } ) } } diff --git a/BetterCapture/View/SettingsView.swift b/BetterCapture/View/SettingsView.swift index 1e6f045..7e99baf 100644 --- a/BetterCapture/View/SettingsView.swift +++ b/BetterCapture/View/SettingsView.swift @@ -69,7 +69,14 @@ struct VideoSettingsView: View { Picker("Codec", selection: $settings.videoCodec) { ForEach(VideoCodec.allCases) { codec in - Text(codec.rawValue).tag(codec) + let isSupported = settings.containerFormat.supportedVideoCodecs.contains(codec) + if isSupported { + Text(codec.rawValue).tag(codec) + } else { + Text("\(codec.rawValue) (not supported for \(settings.containerFormat.rawValue.uppercased()))") + .foregroundStyle(.secondary) + .tag(codec) + } } } @@ -82,7 +89,7 @@ struct VideoSettingsView: View { Section("Advanced") { Toggle("Capture Alpha Channel", isOn: $settings.captureAlphaChannel) - .disabled(!settings.videoCodec.canToggleAlpha) + .disabled(!settings.videoCodec.canToggleAlpha || !settings.containerFormat.supportsAlphaChannel) .help(alphaChannelHelpText) Toggle("HDR Recording", isOn: $settings.captureHDR) @@ -113,10 +120,17 @@ struct AudioSettingsView: View { Section("Format") { Picker("Codec", selection: $settings.audioCodec) { ForEach(AudioCodec.allCases) { codec in - Text(codec.rawValue).tag(codec) + let isSupported = settings.containerFormat.supportedAudioCodecs.contains(codec) + if isSupported { + Text(codec.rawValue).tag(codec) + } else { + Text("\(codec.rawValue) (not supported for \(settings.containerFormat.rawValue.uppercased()))") + .foregroundStyle(.secondary) + .tag(codec) + } } } - .help("AAC is compressed, PCM is uncompressed lossless") + .help("AAC is compressed, PCM is uncompressed lossless (MOV only)") } Section { diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..210e690 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,95 @@ +# Codec and Container Compatibility + +This document describes the compatibility between video/audio codecs, container formats, and feature support in BetterCapture. + +## Container Format Support + +BetterCapture supports two container formats: + +| Container | File Extension | Description | +|-----------|----------------|-------------| +| MOV | `.mov` | Apple QuickTime Movie format. Full feature support including ProRes and alpha channels. | +| MP4 | `.mp4` | MPEG-4 Part 14 format. Wide compatibility but limited codec support. | + +## Video Codec Compatibility Matrix + +| Video Codec | MOV | MP4 | Notes | +|----------------|-----|-----|-------| +| H.264 | Yes | Yes | Most compatible codec, no alpha or HDR support | +| H.265 (HEVC) | Yes | Yes | Better compression than H.264, no alpha in MP4 | +| ProRes 422 | Yes | No | Professional quality, 10-bit, MOV only | +| ProRes 4444 | Yes | No | Highest quality, includes alpha channel, MOV only | + +## Feature Support by Video Codec + +| Video Codec | Alpha Channel | HDR (10-bit) | Notes | +|----------------|---------------|--------------|-------| +| H.264 | No | No | 8-bit SDR only | +| H.265 (HEVC) | Optional* | No | Alpha requires MOV container | +| ProRes 422 | No | Yes | 10-bit HDR support | +| ProRes 4444 | Always | Yes | 10-bit HDR, always includes alpha | + +*HEVC with alpha (`hevcWithAlpha`) is only supported in MOV containers. + +## Audio Codec Compatibility Matrix + +| Audio Codec | MOV | MP4 | Notes | +|-------------|-----|-----|-------| +| AAC | Yes | Yes | Compressed audio, good quality | +| PCM | Yes | No | Uncompressed audio, MOV only | + +## Container Format Restrictions + +### MP4 Restrictions + +When using MP4 container format: +- **Video codecs limited to:** H.264, HEVC (without alpha) +- **Audio codecs limited to:** AAC +- **No alpha channel support** +- **No HDR support** (codecs that support HDR are not compatible with MP4) + +### MOV Capabilities + +When using MOV container format: +- All video codecs supported +- All audio codecs supported +- Full alpha channel support (HEVC with alpha, ProRes 4444) +- HDR support with ProRes codecs + +## Automatic Settings Adjustment + +BetterCapture automatically adjusts settings when changing container formats to maintain compatibility: + +1. **When switching to MP4:** + - If current video codec is ProRes, automatically switches to HEVC + - If alpha channel is enabled, automatically disables it + - If audio codec is PCM, automatically switches to AAC + +2. **When selecting ProRes codecs:** + - If container is MP4, automatically switches to MOV + +3. **When enabling alpha channel:** + - Only allowed if both video codec supports alpha AND container is MOV + +## Recommended Settings + +### For Maximum Compatibility +- Container: MP4 +- Video Codec: H.264 +- Audio Codec: AAC + +### For Professional Quality +- Container: MOV +- Video Codec: ProRes 422 (without alpha) or ProRes 4444 (with alpha) +- Audio Codec: AAC or PCM + +### For Good Quality with Reasonable File Size +- Container: MOV or MP4 +- Video Codec: HEVC +- Audio Codec: AAC + +## Technical References + +- [About Apple ProRes (Apple Support HT202410)](https://support.apple.com/en-us/HT202410) +- [TN3104: Recording video in Apple ProRes](https://developer.apple.com/documentation/technotes/tn3104-recording-video-in-apple-prores) +- [HEVC Video with Alpha Interoperability Profile](https://developer.apple.com/av-foundation/HEVC-Video-with-Alpha-Interoperability-Profile.pdf)