Fall back to software OpenGL renderer instead of crashing on GPU-less Macs

MPVOpenGLLayer called fatalError() when CGLChoosePixelFormat failed for
both the 10-bit and 8-bit (kCGLPFAAccelerated) formats, killing the app at
launch on machines without a usable GPU such as virtual machines.

Add a non-accelerated software pixel format as a last resort and make the
whole OpenGL init path failable: createPixelFormat/createContext return nil
(with an error log) instead of aborting, MPVOpenGLLayer.init becomes init?,
and MPVOGLView holds an optional layer, throwing openGLSetupFailed from
setup(with:) when OpenGL is unavailable. That error is already handled by
MPVBackend.setupMPVAsync, so the app now launches cleanly and only logs that
video playback is unavailable.
This commit is contained in:
Arkadiusz Fal
2026-05-15 07:03:24 +02:00
parent c49ae6c6a6
commit bc81b03821
2 changed files with 85 additions and 37 deletions

View File

@@ -21,9 +21,9 @@ final class MPVOGLView: NSView {
// MARK: - Properties // MARK: - Properties
/// The OpenGL layer that handles rendering. /// The OpenGL layer that handles rendering.
private(set) lazy var videoLayer: MPVOpenGLLayer = { /// `nil` when OpenGL is unavailable (e.g. a GPU-less virtual machine); the
MPVOpenGLLayer(videoView: self) /// view then renders nothing and `setup(with:)` throws instead of crashing.
}() private(set) var videoLayer: MPVOpenGLLayer?
/// Reference to the MPV client. /// Reference to the MPV client.
private weak var mpvClient: MPVClient? private weak var mpvClient: MPVClient?
@@ -44,8 +44,8 @@ final class MPVOGLView: NSView {
/// Callback when first frame is rendered. /// Callback when first frame is rendered.
var onFirstFrameRendered: (() -> Void)? { var onFirstFrameRendered: (() -> Void)? {
get { videoLayer.onFirstFrameRendered } get { videoLayer?.onFirstFrameRendered }
set { videoLayer.onFirstFrameRendered = newValue } set { videoLayer?.onFirstFrameRendered = newValue }
} }
// MARK: - Video Info // MARK: - Video Info
@@ -65,32 +65,32 @@ final class MPVOGLView: NSView {
/// Whether to capture frames for PiP. /// Whether to capture frames for PiP.
var captureFramesForPiP: Bool { var captureFramesForPiP: Bool {
get { videoLayer.captureFramesForPiP } get { videoLayer?.captureFramesForPiP ?? false }
set { videoLayer.captureFramesForPiP = newValue } set { videoLayer?.captureFramesForPiP = newValue }
} }
/// Whether PiP is currently active. /// Whether PiP is currently active.
var isPiPActive: Bool { var isPiPActive: Bool {
get { videoLayer.isPiPActive } get { videoLayer?.isPiPActive ?? false }
set { videoLayer.isPiPActive = newValue } set { videoLayer?.isPiPActive = newValue }
} }
/// Callback when a new frame is ready for PiP. /// Callback when a new frame is ready for PiP.
var onFrameReady: ((CVPixelBuffer, CMTime) -> Void)? { var onFrameReady: ((CVPixelBuffer, CMTime) -> Void)? {
get { videoLayer.onFrameReady } get { videoLayer?.onFrameReady }
set { videoLayer.onFrameReady = newValue } set { videoLayer?.onFrameReady = newValue }
} }
/// Video content width (actual video dimensions for letterbox cropping). /// Video content width (actual video dimensions for letterbox cropping).
var videoContentWidth: Int { var videoContentWidth: Int {
get { videoLayer.videoContentWidth } get { videoLayer?.videoContentWidth ?? 0 }
set { videoLayer.videoContentWidth = newValue } set { videoLayer?.videoContentWidth = newValue }
} }
/// Video content height (actual video dimensions for letterbox cropping). /// Video content height (actual video dimensions for letterbox cropping).
var videoContentHeight: Int { var videoContentHeight: Int {
get { videoLayer.videoContentHeight } get { videoLayer?.videoContentHeight ?? 0 }
set { videoLayer.videoContentHeight = newValue } set { videoLayer?.videoContentHeight = newValue }
} }
// MARK: - Initialization // MARK: - Initialization
@@ -113,10 +113,17 @@ final class MPVOGLView: NSView {
private func commonInit() { private func commonInit() {
// Set up layer-backed view // Set up layer-backed view
wantsLayer = true wantsLayer = true
layer = videoLayer
// Configure layer properties // Create the OpenGL layer. This can fail on machines without a usable
videoLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 // GPU (e.g. virtual machines), in which case the view renders nothing
// and setup(with:) will throw rather than crashing the app at launch.
if let videoLayer = MPVOpenGLLayer(videoView: self) {
self.videoLayer = videoLayer
layer = videoLayer
videoLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
} else {
LoggingService.shared.error("MPVOGLView: OpenGL unavailable - video rendering disabled", category: .mpv)
}
// Configure view properties // Configure view properties
autoresizingMask = [.width, .height] autoresizingMask = [.width, .height]
@@ -132,6 +139,11 @@ final class MPVOGLView: NSView {
func setup(with client: MPVClient) throws { func setup(with client: MPVClient) throws {
self.mpvClient = client self.mpvClient = client
// No layer means OpenGL was unavailable at init (e.g. GPU-less VM).
guard let videoLayer else {
throw MPVRenderError.openGLSetupFailed
}
// Set up the layer // Set up the layer
try videoLayer.setup(with: client) try videoLayer.setup(with: client)
@@ -157,7 +169,7 @@ final class MPVOGLView: NSView {
startDisplayLink() startDisplayLink()
// Update contents scale for new window // Update contents scale for new window
videoLayer.contentsScale = window.backingScaleFactor videoLayer?.contentsScale = window.backingScaleFactor
} }
} }
@@ -166,7 +178,7 @@ final class MPVOGLView: NSView {
// Update contents scale when backing properties change // Update contents scale when backing properties change
if let scale = window?.backingScaleFactor { if let scale = window?.backingScaleFactor {
videoLayer.contentsScale = scale videoLayer?.contentsScale = scale
} }
// Update display refresh rate // Update display refresh rate
@@ -238,12 +250,12 @@ final class MPVOGLView: NSView {
/// Reset first frame tracking (call when loading new content). /// Reset first frame tracking (call when loading new content).
func resetFirstFrameTracking() { func resetFirstFrameTracking() {
mpvHasFrameReady = false mpvHasFrameReady = false
videoLayer.resetFirstFrameTracking() videoLayer?.resetFirstFrameTracking()
} }
/// Clear the view to black. /// Clear the view to black.
func clearToBlack() { func clearToBlack() {
videoLayer.clearToBlack() videoLayer?.clearToBlack()
} }
/// Pause rendering. /// Pause rendering.
@@ -254,12 +266,12 @@ final class MPVOGLView: NSView {
/// Resume rendering. /// Resume rendering.
func resumeRendering() { func resumeRendering() {
videoLayer.update(force: true) videoLayer?.update(force: true)
} }
/// Update cached time position for PiP timestamps. /// Update cached time position for PiP timestamps.
func updateTimePosition(_ time: Double) { func updateTimePosition(_ time: Double) {
videoLayer.updateTimePosition(time) videoLayer?.updateTimePosition(time)
} }
/// Clear the main view for PiP transition (stub for now). /// Clear the main view for PiP transition (stub for now).
@@ -269,7 +281,7 @@ final class MPVOGLView: NSView {
/// Update PiP target render size - forces recreation of PiP capture resources. /// Update PiP target render size - forces recreation of PiP capture resources.
func updatePiPTargetSize(_ size: CMVideoDimensions) { func updatePiPTargetSize(_ size: CMVideoDimensions) {
videoLayer.updatePiPTargetSize(size) videoLayer?.updatePiPTargetSize(size)
} }
// MARK: - Cleanup // MARK: - Cleanup
@@ -283,7 +295,7 @@ final class MPVOGLView: NSView {
isUninited = true isUninited = true
stopDisplayLink() stopDisplayLink()
videoLayer.uninit() videoLayer?.uninit()
// Note: onFirstFrameRendered and onFrameReady are forwarded to videoLayer, // Note: onFirstFrameRendered and onFrameReady are forwarded to videoLayer,
// and videoLayer.uninit() clears them // and videoLayer.uninit() clears them

View File

@@ -38,6 +38,17 @@ private let glFormat10Bit: [CGLPixelFormatAttribute] = [
CGLPixelFormatAttribute(0) CGLPixelFormatAttribute(0)
] ]
/// Last-resort pixel format without `kCGLPFAAccelerated`, allowing a software
/// renderer. This is what lets the app survive on GPU-less environments such as
/// virtual machines, where no hardware-accelerated renderer is available.
private let glFormatSoftware: [CGLPixelFormatAttribute] = [
kCGLPFAOpenGLProfile,
CGLPixelFormatAttribute(kCGLOGLPVersion_3_2_Core.rawValue),
kCGLPFADoubleBuffer,
kCGLPFAAllowOfflineRenderers,
CGLPixelFormatAttribute(0)
]
// MARK: - MPVOpenGLLayer // MARK: - MPVOpenGLLayer
/// OpenGL layer for MPV rendering on macOS. /// OpenGL layer for MPV rendering on macOS.
@@ -141,16 +152,25 @@ final class MPVOpenGLLayer: CAOpenGLLayer {
// MARK: - Initialization // MARK: - Initialization
/// Creates an MPVOpenGLLayer for the given video view. /// Creates an MPVOpenGLLayer for the given video view.
init(videoView: MPVOGLView) { /// Returns `nil` when no OpenGL pixel format / context can be created
/// (e.g. on a GPU-less virtual machine), so callers can fail gracefully
/// instead of crashing the app at launch.
init?(videoView: MPVOGLView) {
self.videoView = videoView self.videoView = videoView
// Create pixel format (try 10-bit first, fall back to 8-bit) // Create pixel format (try 10-bit, then 8-bit, then software renderer)
let (pixelFormat, depth) = MPVOpenGLLayer.createPixelFormat() guard let (pixelFormat, depth) = MPVOpenGLLayer.createPixelFormat() else {
return nil
}
self.cglPixelFormat = pixelFormat self.cglPixelFormat = pixelFormat
self.bufferDepth = depth self.bufferDepth = depth
// Create OpenGL context // Create OpenGL context
self.cglContext = MPVOpenGLLayer.createContext(pixelFormat: pixelFormat) guard let context = MPVOpenGLLayer.createContext(pixelFormat: pixelFormat) else {
CGLDestroyPixelFormat(pixelFormat)
return nil
}
self.cglContext = context
super.init() super.init()
@@ -560,8 +580,11 @@ final class MPVOpenGLLayer: CAOpenGLLayer {
// MARK: - Pixel Format and Context Creation // MARK: - Pixel Format and Context Creation
/// Create a CGL pixel format, trying 10-bit first, falling back to 8-bit. /// Create a CGL pixel format, trying 10-bit first, falling back to 8-bit,
private static func createPixelFormat() -> (CGLPixelFormatObj, GLint) { /// then to a software renderer. Returns `nil` if no pixel format can be
/// created (e.g. a GPU-less virtual machine) so the caller can fail
/// gracefully instead of crashing.
private static func createPixelFormat() -> (CGLPixelFormatObj, GLint)? {
var pixelFormat: CGLPixelFormatObj? var pixelFormat: CGLPixelFormatObj?
var numPixelFormats: GLint = 0 var numPixelFormats: GLint = 0
@@ -583,17 +606,30 @@ final class MPVOpenGLLayer: CAOpenGLLayer {
return (pf, 8) return (pf, 8)
} }
// This should not happen on any reasonable Mac // Last resort: software renderer (no hardware acceleration). This keeps
fatalError("MPVOpenGLLayer: failed to create any OpenGL pixel format") // the app alive on machines without a usable GPU, such as VMs.
result = CGLChoosePixelFormat(glFormatSoftware, &pixelFormat, &numPixelFormats)
if result == kCGLNoError, let pf = pixelFormat {
Task { @MainActor in
LoggingService.shared.debug("MPVOpenGLLayer: created software (non-accelerated) pixel format", category: .mpv)
}
return (pf, 8)
}
// No pixel format available at all (e.g. GPU-less VM). Don't crash.
LoggingService.shared.error("MPVOpenGLLayer: failed to create any OpenGL pixel format (last error \(result.rawValue)) - video playback unavailable", category: .mpv)
return nil
} }
/// Create a CGL context with the given pixel format. /// Create a CGL context with the given pixel format. Returns `nil` on
private static func createContext(pixelFormat: CGLPixelFormatObj) -> CGLContextObj { /// failure so the caller can fail gracefully instead of crashing.
private static func createContext(pixelFormat: CGLPixelFormatObj) -> CGLContextObj? {
var context: CGLContextObj? var context: CGLContextObj?
let result = CGLCreateContext(pixelFormat, nil, &context) let result = CGLCreateContext(pixelFormat, nil, &context)
guard result == kCGLNoError, let ctx = context else { guard result == kCGLNoError, let ctx = context else {
fatalError("MPVOpenGLLayer: failed to create OpenGL context: \(result)") LoggingService.shared.error("MPVOpenGLLayer: failed to create OpenGL context: \(result.rawValue)", category: .mpv)
return nil
} }
// Enable vsync // Enable vsync