AppKitVideoRenderer

Draws frames into an AppKitWindow.

The conversion from the decoder's pixel format to RGBA is supplied by the caller through convert, because it lives in whichever backend produced the frame: the software path in kiteplayer-ffmpeg knows how to read that decoder's planes, and this module must not depend on it.

Why it has its own worker

Converting a frame on the CPU and building an image from it costs milliseconds, and at 1080p enough of them that doing it inside present starves everything: the presentation schedule slips, and with the process saturated the audio feeder cannot keep the device fed either. Measured that way, a 1080p clip produced 700 audio underruns and showed 11 frames out of 300.

So present hands the frame to a worker and returns at once, which is what the renderer contract allows for exactly this reason. Only the newest frame is kept: when the renderer cannot keep up, a new frame replaces the one still waiting rather than a queue building. That is the right trade for a slow renderer. The engine's schedule stays intact and the picture updates as often as the renderer manages, instead of the whole player being dragged down to its speed.

The one-slot handover is written out by hand rather than using a conflated channel, and that is not fussiness. A conflated channel discards the displaced element silently, so the frame it drops is never closed and never counted: the first version of this class leaked 291 of 300 frames that way, and reported having drawn 9 with nothing dropped. Owning the slot means the displaced frame is closed and counted, which is both correct and visible in supersededFrames.

Two slots, not one

There is a second slot on the other side of the worker, for the same reason as the first. AppKit can only be touched from the main thread, so a finished image has to be handed over to it, and the main thread is not the renderer's to schedule: it belongs to the user interface and can be busy for as long as it likes. Posting every finished image to it puts unbounded work in a queue nobody drains, and every one of those images holds a full frame of pixels.

So the worker stores the finished image in its own latest-only slot and queues at most one delivery block at a time. A newer image replaces the one still waiting, and the block that eventually runs draws whatever is in the slot then, which is the newest picture there is. A slow main thread costs smoothness and one image of memory, never a growing backlog.

The rules this obeys

The frame belongs to this renderer from the moment present is called, and is closed exactly once, including when it is superseded before being drawn, when conversion fails, and when the renderer is closed while it is still in hand. AppKit is touched only from the main thread, through dispatch_async, and never waited on: blocking the engine on the UI thread is how a player deadlocks, and libmpv's own headers warn about that twice.

A frame that carries a VideoFrame.rotationDegrees is drawn turned, so a recording made in portrait is not shown on its side. The turn is a second pass over the pixels and is only paid by a clip whose container asks for one.

close is the one place that blocks, and only on this renderer's own worker. It marks the renderer closed, ends the worker's wait, then waits for a conversion already running to finish before draining both slots and closing the conversion thread. That order is what makes the two drains final: nothing else is left that could put a frame or an image back.

Constructors

Link copied to clipboard
constructor(window: AppKitWindow, convert: (VideoFrame) -> ByteArray, toneMapped: (VideoFrame) -> Boolean = { false })

The original constructor keeps the display awake while pictures are shown.

constructor(window: AppKitWindow, convert: (VideoFrame) -> ByteArray, toneMapped: (VideoFrame) -> Boolean = { false }, keepDisplayAwake: Boolean = true)

The renderer as a player uses it: finished images go into window's image view, on the main thread, through the main queue.

Properties

Link copied to clipboard
open override val events: Flow<RendererEvent>
Link copied to clipboard

Frames that reached no window for a reason other than being superseded: a conversion that failed, a frame with no pixels in it, or a frame still in hand when the renderer closed.

Link copied to clipboard
Link copied to clipboard

Frames whose picture reached the window.

Link copied to clipboard
Link copied to clipboard
open val showsHdr: Boolean
Link copied to clipboard

Frames replaced by a newer one before they could be drawn, in either slot.

Functions

Link copied to clipboard
open fun accepts(shape: FrameShape): Boolean
Link copied to clipboard
open override fun clearPicture()

Lets go of a frame waiting to convert, counted superseded, and has the worker forget the retained picture and show the background with the cues over it.

Link copied to clipboard
open override fun close()

Stops drawing and gives everything back.

Link copied to clipboard
open suspend override fun present(frame: VideoFrame, targetNanos: Long): Boolean

Queues frame for drawing and returns immediately.

Link copied to clipboard
open override fun setAdjustments(adjustments: VideoAdjustments)

A paused picture shows the change too: the retained pixels re-draw.

Link copied to clipboard
open fun setHdrPolicy(policy: HdrPolicy)
Link copied to clipboard
open suspend override fun setOverlay(overlay: SubtitleOverlay?)
Link copied to clipboard
open fun setRenderQuality(quality: RenderQuality)
Link copied to clipboard
open fun setScaleMode(mode: VideoScale)
Link copied to clipboard
open override fun setTransform(transform: VideoTransform)

The framing half. Same delivery law as setAdjustments.

Link copied to clipboard
open override fun setViewport(width: Int, height: Int, scale: Float)
Link copied to clipboard

Nothing zero-copy here. That is what a Metal renderer is for.

Link copied to clipboard
open override fun supports(format: PlayerPixelFormat): Boolean
Link copied to clipboard
Link copied to clipboard
open override fun vsyncIntervalNanos(): Long?