Fix macOS permanent black video after player collapse/expand cycles

In separate-window mode, hide() keeps the ordered-out window's SwiftUI
hierarchy alive (nilling contentViewController crashes AVKit), and its
updateNSView unconditionally re-attached the shared MPVOGLView -
stealing it into a window CoreAnimation never composites. The layer's
silent skip-render fallback then consumed every frame flag, so video
stayed black (climbing vo drops) until app restart.

- Decline stealing the shared view from a visible container into a
  non-visible one; owner-initiated transfers bypass the guard
- Only transfer to containers in visible windows or the window still
  tracked by ExpandedPlayerWindowManager; park the view otherwise and
  reclaim it when a container gains a window
- Log skip-render frame consumption (rate-limited) and track render
  health counters (draws/skips/dropped draws)
- Add a watchdog to the playback stats task that detects a stalled
  video output and re-attaches the view to a visible container
- Don't mount player layouts during zero-size layout passes
This commit is contained in:
Arkadiusz Fal
2026-06-21 21:55:37 +02:00
parent 1cd566ae29
commit b82241564d
6 changed files with 270 additions and 15 deletions

View File

@@ -272,6 +272,26 @@ final class MPVOGLView: NSView {
videoLayer?.resetFirstFrameTracking()
}
/// Thread-safe snapshot of the layer's render-health counters.
func renderHealthSnapshot() -> MPVRenderHealth? {
videoLayer?.renderHealthSnapshot()
}
/// Human-readable description of where this shared view is currently
/// attached, for render-health diagnostics. Main thread only.
var attachmentDescription: String {
let superviewDesc: String
if let container = superview as? MPVContainerNSView {
superviewDesc = "container=\(container.shortID)"
} else if let superview {
superviewDesc = "superview=\(type(of: superview))"
} else {
superviewDesc = "superview=nil"
}
let windowDesc = window.map { "window=#\($0.windowNumber) visible=\($0.isVisible)" } ?? "window=nil"
return "\(superviewDesc) \(windowDesc) bounds=\(Int(bounds.width))x\(Int(bounds.height))"
}
/// Clear the view to black.
func clearToBlack() {
videoLayer?.clearToBlack()

View File

@@ -49,6 +49,19 @@ private let glFormatSoftware: [CGLPixelFormatAttribute] = [
CGLPixelFormatAttribute(0)
]
// MARK: - Render Health
/// Cumulative render-health counters for the MPV OpenGL layer. `skips` counts
/// frames consumed via the skip-render fallback (frame flag cleared without a
/// real draw) a climbing skip count with zero draws means the layer is not
/// being composited and the video output is stalled.
struct MPVRenderHealth {
var draws: UInt64
var skips: UInt64
var droppedDraws: UInt64
var lastDrawAge: TimeInterval?
}
// MARK: - MPVOpenGLLayer
/// OpenGL layer for MPV rendering on macOS.
@@ -94,6 +107,16 @@ final class MPVOpenGLLayer: CAOpenGLLayer {
/// True while a forced draw has been requested but not yet executed.
var hasPendingForcedDraw: Bool { forceDrawLock.withLock { forceDraw } }
/// Render-health counters (see `MPVRenderHealth`). Touched on the render
/// queue in `draw`/`display`, read from the main actor by the playback
/// stats watchdog.
private var successfulDrawCount: UInt64 = 0
private var skipRenderCount: UInt64 = 0
private var droppedDrawCount: UInt64 = 0
private var lastSuccessfulDrawTime: CFTimeInterval?
private var skipsSinceLastDraw: UInt64 = 0
private let renderHealthLock = NSLock()
/// Whether the layer has been set up with an MPV client.
private var isSetup = false
@@ -345,6 +368,7 @@ final class MPVOpenGLLayer: CAOpenGLLayer {
category: .mpv
)
}
renderHealthLock.withLock { droppedDrawCount += 1 }
scheduleDroppedDrawRetry()
return
}
@@ -353,6 +377,11 @@ final class MPVOpenGLLayer: CAOpenGLLayer {
needsFlipLock.withLock { needsFlip = false }
forceDrawLock.withLock { forceDraw = false }
droppedDrawRetryLock.withLock { droppedDrawRetries = 0 }
renderHealthLock.withLock {
successfulDrawCount += 1
skipsSinceLastDraw = 0
lastSuccessfulDrawTime = CACurrentMediaTime()
}
// Clear the buffer
glClear(GLbitfield(GL_COLOR_BUFFER_BIT))
@@ -437,6 +466,25 @@ final class MPVOpenGLLayer: CAOpenGLLayer {
guard let mpvClient, let renderContext = mpvClient.mpvRenderContext,
mpvClient.shouldRenderUpdateFrame() else { return }
// A long uninterrupted run of skips means the layer is not being
// composited (e.g. the shared view is parented in a non-visible
// window) and playback is silently video-less. Log the first skip
// after a real draw, then every ~300 (~10s at 30fps).
let skips = renderHealthLock.withLock { () -> UInt64 in
skipRenderCount += 1
skipsSinceLastDraw += 1
return skipsSinceLastDraw
}
if skips == 1 || skips.isMultiple(of: 300) {
let boundsSize = bounds.size
Task { @MainActor in
LoggingService.shared.warning(
"MPVOpenGLLayer: skip-render consumed frame without draw (skips=\(skips) since last draw, bounds=\(boundsSize))",
category: .mpv
)
}
}
// Must lock OpenGL context before calling mpv render functions
mpvClient.lockAndSetOpenGLContext()
defer { mpvClient.unlockOpenGLContext() }
@@ -648,6 +696,18 @@ final class MPVOpenGLLayer: CAOpenGLLayer {
hasRenderedFirstFrame = false
}
/// Thread-safe snapshot of the render-health counters.
func renderHealthSnapshot() -> MPVRenderHealth {
renderHealthLock.withLock {
MPVRenderHealth(
draws: successfulDrawCount,
skips: skipRenderCount,
droppedDraws: droppedDrawCount,
lastDrawAge: lastSuccessfulDrawTime.map { CACurrentMediaTime() - $0 }
)
}
}
// MARK: - Pixel Format and Context Creation
/// Create a CGL pixel format, trying 10-bit first, falling back to 8-bit,

View File

@@ -2048,15 +2048,64 @@ extension MPVBackend: MPVClientDelegate {
guard playbackStatsTask == nil else { return }
playbackStatsTask = Task { @MainActor [weak self] in
#if os(macOS)
var previousHealth: MPVRenderHealth?
var previousVoDrops: Int?
var watchdogFiredLastTick = false
#endif
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(10))
guard let self, !Task.isCancelled, let client = self.mpvClient else { return }
let props = await client.getDebugPropertiesAsync()
#if os(macOS)
// Render watchdog runs every tick regardless of the verbose
// gate. Detects a stalled video output: playback advancing and
// frames being consumed (skip-render) or dropped by the VO,
// but not a single successful draw since the last tick. That
// is the "permanent black video" signature (shared render view
// parented in a non-visible window); attempt recovery by
// re-attaching the view to a visible container.
let health = self._playerView?.renderHealthSnapshot()
if let health {
let drawsDelta = health.draws &- (previousHealth?.draws ?? health.draws)
let skipsDelta = health.skips &- (previousHealth?.skips ?? health.skips)
let voDrops = props.frameDropCount
let voDropsDelta = (voDrops ?? 0) - (previousVoDrops ?? voDrops ?? 0)
if watchdogFiredLastTick {
let outcome = drawsDelta > 0 ? "succeeded" : "failed"
LoggingService.shared.warning(
"MPV render watchdog: recovery \(outcome) (draws=+\(drawsDelta), skips=+\(skipsDelta), attach: \(self._playerView?.attachmentDescription ?? "no view"))",
category: .mpv
)
watchdogFiredLastTick = false
}
if self.isPlaying, !self.isPiPActive, previousHealth != nil,
drawsDelta == 0, skipsDelta > 20 || voDropsDelta > 50 {
LoggingService.shared.warning(
"MPV render watchdog: video output stalled (draws=+0, skips=+\(skipsDelta), voDrops=+\(voDropsDelta), attach: \(self._playerView?.attachmentDescription ?? "no view")) - attempting recovery",
category: .mpv
)
MPVContainerNSView.recoverSharedPlayerViewIfNeeded()
self._playerView?.resumeRendering()
watchdogFiredLastTick = true
}
previousVoDrops = voDrops ?? previousVoDrops
}
#endif
// Gate on the verbose setting each tick (not at task start) so
// toggling it mid-playback takes effect without a reload
guard MPVLogging.verboseEnabled else { continue }
let props = await client.getDebugPropertiesAsync()
guard MPVLogging.verboseEnabled else {
#if os(macOS)
previousHealth = health
#endif
continue
}
func megabytes(_ bytes: Int64) -> String {
String(format: "%.1fMiB", Double(bytes) / 1_048_576)
@@ -2081,6 +2130,19 @@ extension MPVBackend: MPVClientDelegate {
}
if !cache.isEmpty { parts.append("cache(\(cache.joined(separator: " ")))") }
#if os(macOS)
if let health {
let drawsDelta = health.draws &- (previousHealth?.draws ?? health.draws)
let skipsDelta = health.skips &- (previousHealth?.skips ?? health.skips)
let droppedDelta = health.droppedDraws &- (previousHealth?.droppedDraws ?? health.droppedDraws)
parts.append("render(draws=+\(drawsDelta) skips=+\(skipsDelta) dropped=+\(droppedDelta))")
}
if let view = self._playerView {
parts.append("attach(\(view.attachmentDescription))")
}
previousHealth = health
#endif
LoggingService.shared.info("MPV playback stats: \(parts.joined(separator: " "))", category: .mpv)
}
}