mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
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:
@@ -21,9 +21,9 @@ final class MPVOGLView: NSView {
|
||||
// MARK: - Properties
|
||||
|
||||
/// The OpenGL layer that handles rendering.
|
||||
private(set) lazy var videoLayer: MPVOpenGLLayer = {
|
||||
MPVOpenGLLayer(videoView: self)
|
||||
}()
|
||||
/// `nil` when OpenGL is unavailable (e.g. a GPU-less virtual machine); the
|
||||
/// view then renders nothing and `setup(with:)` throws instead of crashing.
|
||||
private(set) var videoLayer: MPVOpenGLLayer?
|
||||
|
||||
/// Reference to the MPV client.
|
||||
private weak var mpvClient: MPVClient?
|
||||
@@ -44,8 +44,8 @@ final class MPVOGLView: NSView {
|
||||
|
||||
/// Callback when first frame is rendered.
|
||||
var onFirstFrameRendered: (() -> Void)? {
|
||||
get { videoLayer.onFirstFrameRendered }
|
||||
set { videoLayer.onFirstFrameRendered = newValue }
|
||||
get { videoLayer?.onFirstFrameRendered }
|
||||
set { videoLayer?.onFirstFrameRendered = newValue }
|
||||
}
|
||||
|
||||
// MARK: - Video Info
|
||||
@@ -65,32 +65,32 @@ final class MPVOGLView: NSView {
|
||||
|
||||
/// Whether to capture frames for PiP.
|
||||
var captureFramesForPiP: Bool {
|
||||
get { videoLayer.captureFramesForPiP }
|
||||
set { videoLayer.captureFramesForPiP = newValue }
|
||||
get { videoLayer?.captureFramesForPiP ?? false }
|
||||
set { videoLayer?.captureFramesForPiP = newValue }
|
||||
}
|
||||
|
||||
/// Whether PiP is currently active.
|
||||
var isPiPActive: Bool {
|
||||
get { videoLayer.isPiPActive }
|
||||
set { videoLayer.isPiPActive = newValue }
|
||||
get { videoLayer?.isPiPActive ?? false }
|
||||
set { videoLayer?.isPiPActive = newValue }
|
||||
}
|
||||
|
||||
/// Callback when a new frame is ready for PiP.
|
||||
var onFrameReady: ((CVPixelBuffer, CMTime) -> Void)? {
|
||||
get { videoLayer.onFrameReady }
|
||||
set { videoLayer.onFrameReady = newValue }
|
||||
get { videoLayer?.onFrameReady }
|
||||
set { videoLayer?.onFrameReady = newValue }
|
||||
}
|
||||
|
||||
/// Video content width (actual video dimensions for letterbox cropping).
|
||||
var videoContentWidth: Int {
|
||||
get { videoLayer.videoContentWidth }
|
||||
set { videoLayer.videoContentWidth = newValue }
|
||||
get { videoLayer?.videoContentWidth ?? 0 }
|
||||
set { videoLayer?.videoContentWidth = newValue }
|
||||
}
|
||||
|
||||
/// Video content height (actual video dimensions for letterbox cropping).
|
||||
var videoContentHeight: Int {
|
||||
get { videoLayer.videoContentHeight }
|
||||
set { videoLayer.videoContentHeight = newValue }
|
||||
get { videoLayer?.videoContentHeight ?? 0 }
|
||||
set { videoLayer?.videoContentHeight = newValue }
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
@@ -113,10 +113,17 @@ final class MPVOGLView: NSView {
|
||||
private func commonInit() {
|
||||
// Set up layer-backed view
|
||||
wantsLayer = true
|
||||
layer = videoLayer
|
||||
|
||||
// Configure layer properties
|
||||
videoLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
|
||||
// Create the OpenGL layer. This can fail on machines without a usable
|
||||
// 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
|
||||
autoresizingMask = [.width, .height]
|
||||
@@ -132,6 +139,11 @@ final class MPVOGLView: NSView {
|
||||
func setup(with client: MPVClient) throws {
|
||||
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
|
||||
try videoLayer.setup(with: client)
|
||||
|
||||
@@ -157,7 +169,7 @@ final class MPVOGLView: NSView {
|
||||
startDisplayLink()
|
||||
|
||||
// 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
|
||||
if let scale = window?.backingScaleFactor {
|
||||
videoLayer.contentsScale = scale
|
||||
videoLayer?.contentsScale = scale
|
||||
}
|
||||
|
||||
// Update display refresh rate
|
||||
@@ -238,12 +250,12 @@ final class MPVOGLView: NSView {
|
||||
/// Reset first frame tracking (call when loading new content).
|
||||
func resetFirstFrameTracking() {
|
||||
mpvHasFrameReady = false
|
||||
videoLayer.resetFirstFrameTracking()
|
||||
videoLayer?.resetFirstFrameTracking()
|
||||
}
|
||||
|
||||
/// Clear the view to black.
|
||||
func clearToBlack() {
|
||||
videoLayer.clearToBlack()
|
||||
videoLayer?.clearToBlack()
|
||||
}
|
||||
|
||||
/// Pause rendering.
|
||||
@@ -254,12 +266,12 @@ final class MPVOGLView: NSView {
|
||||
|
||||
/// Resume rendering.
|
||||
func resumeRendering() {
|
||||
videoLayer.update(force: true)
|
||||
videoLayer?.update(force: true)
|
||||
}
|
||||
|
||||
/// Update cached time position for PiP timestamps.
|
||||
func updateTimePosition(_ time: Double) {
|
||||
videoLayer.updateTimePosition(time)
|
||||
videoLayer?.updateTimePosition(time)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
func updatePiPTargetSize(_ size: CMVideoDimensions) {
|
||||
videoLayer.updatePiPTargetSize(size)
|
||||
videoLayer?.updatePiPTargetSize(size)
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
@@ -283,7 +295,7 @@ final class MPVOGLView: NSView {
|
||||
isUninited = true
|
||||
|
||||
stopDisplayLink()
|
||||
videoLayer.uninit()
|
||||
videoLayer?.uninit()
|
||||
|
||||
// Note: onFirstFrameRendered and onFrameReady are forwarded to videoLayer,
|
||||
// and videoLayer.uninit() clears them
|
||||
|
||||
@@ -38,6 +38,17 @@ private let glFormat10Bit: [CGLPixelFormatAttribute] = [
|
||||
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
|
||||
|
||||
/// OpenGL layer for MPV rendering on macOS.
|
||||
@@ -141,16 +152,25 @@ final class MPVOpenGLLayer: CAOpenGLLayer {
|
||||
// MARK: - Initialization
|
||||
|
||||
/// 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
|
||||
|
||||
// Create pixel format (try 10-bit first, fall back to 8-bit)
|
||||
let (pixelFormat, depth) = MPVOpenGLLayer.createPixelFormat()
|
||||
// Create pixel format (try 10-bit, then 8-bit, then software renderer)
|
||||
guard let (pixelFormat, depth) = MPVOpenGLLayer.createPixelFormat() else {
|
||||
return nil
|
||||
}
|
||||
self.cglPixelFormat = pixelFormat
|
||||
self.bufferDepth = depth
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -560,8 +580,11 @@ final class MPVOpenGLLayer: CAOpenGLLayer {
|
||||
|
||||
// MARK: - Pixel Format and Context Creation
|
||||
|
||||
/// Create a CGL pixel format, trying 10-bit first, falling back to 8-bit.
|
||||
private static func createPixelFormat() -> (CGLPixelFormatObj, GLint) {
|
||||
/// Create a CGL pixel format, trying 10-bit first, falling back to 8-bit,
|
||||
/// 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 numPixelFormats: GLint = 0
|
||||
|
||||
@@ -583,17 +606,30 @@ final class MPVOpenGLLayer: CAOpenGLLayer {
|
||||
return (pf, 8)
|
||||
}
|
||||
|
||||
// This should not happen on any reasonable Mac
|
||||
fatalError("MPVOpenGLLayer: failed to create any OpenGL pixel format")
|
||||
// Last resort: software renderer (no hardware acceleration). This keeps
|
||||
// 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.
|
||||
private static func createContext(pixelFormat: CGLPixelFormatObj) -> CGLContextObj {
|
||||
/// Create a CGL context with the given pixel format. Returns `nil` on
|
||||
/// failure so the caller can fail gracefully instead of crashing.
|
||||
private static func createContext(pixelFormat: CGLPixelFormatObj) -> CGLContextObj? {
|
||||
var context: CGLContextObj?
|
||||
let result = CGLCreateContext(pixelFormat, nil, &context)
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user