Skip to content

FrameSetPacer.kt

src/jvmMain/kotlin/afield/graphics/texture/video/FrameSetPacer.kt · 136 lines

Paces an asynchronous frame set so each frame gets a whole source-frame interval to prepare before it is due.

A slow preparation stays the pending demand even when the wall clock has moved past it, which keeps a slow decoder from being starved; seeks and reconfiguration may still replace it. FrameSetBarrierTest exercises it alongside FrameSetBarrier.

package afield.graphics.texture.video

import kotlin.math.abs

class FrameSetPacer(private val frameCount: Int)

Gives an asynchronous frame set its whole display interval in which to prepare.

After frame N is presented, N+1 becomes the pending demand immediately, but it is not due until one source-frame interval later. A slow preparation remains the pending demand even when an external wall clock has moved farther ahead: dropping that useful work is the starvation loop this class exists to prevent. Explicit seeks/reconfiguration revisions may still supersede it.

data class Demand(val frame: Int, val notBeforeNanos: Long, val generation: Long)

fun isDue(nowNanos: Long): Boolean = nowNanos >= notBeforeNanos

    private data class Cadence(val playing: Boolean, val rate: Double, val loop: Boolean)

init

    init {
        require(frameCount >= 1) { "frameCount must be at least one" }
    }

    private var revision: Long? = null
    private var cadence: Cadence? = null
    private var pending: Demand? = null
    private var presentedFrame: Int? = null
    private var lastPresentationDeadline = Long.MIN_VALUE
    private var nextGeneration = 0L

val generation: Long get() = nextGeneration

Counter incremented whenever the pacer throws away its pending demand: on a new revision, on a change of cadence, and on reset. Every FrameSetPacer.Demand carries the value current when it was made, so work begun for an older generation can be recognised and dropped.

val pendingFrame: Int? get() = pending?.frame

Index of the frame select is currently asking for, or null when nothing is outstanding. markPresented clears it.

fun select

body — 48 lines, from line 53
    fun select(
        requestedFrame: Int,
        playing: Boolean,
        sourceFramesPerSecond: Double,
        loop: Boolean,
        revision: Long,
        nowNanos: Long
    ): Demand? {
        require(requestedFrame in 0 until frameCount)
        require(sourceFramesPerSecond.isFinite())
        val nextCadence = Cadence(playing, sourceFramesPerSecond, loop)
        val explicitChange = this.revision != revision
        val cadenceChange = cadence != nextCadence
        if (explicitChange || cadenceChange) {
            this.revision = revision
            cadence = nextCadence
            pending = null
            nextGeneration++
            val frame = when {
                explicitChange || presentedFrame == null -> requestedFrame
                !playing || sourceFramesPerSecond == 0.0 ->
                    requestedFrame.takeIf { it != presentedFrame }
                else -> adjacent(checkNotNull(presentedFrame), sourceFramesPerSecond, loop)
            }
            pending = frame?.let {
                Demand(
                    frame = it,
                    notBeforeNanos = if (explicitChange || presentedFrame == null) nowNanos
                    else nowNanos + intervalNanos(sourceFramesPerSecond),
                    generation = nextGeneration
                )
            }
        }
        pending?.let { return it }

        val shown = presentedFrame
        if (shown == null) {
            return Demand(requestedFrame, nowNanos, nextGeneration).also { pending = it }
        }
        if (!playing || sourceFramesPerSecond == 0.0) {
            return requestedFrame.takeIf { it != shown }
                ?.let { Demand(it, nowNanos, nextGeneration).also { demand -> pending = demand } }
        }
        val frame = adjacent(shown, sourceFramesPerSecond, loop) ?: return null
        val due = if (lastPresentationDeadline == Long.MIN_VALUE) nowNanos
        else lastPresentationDeadline + intervalNanos(sourceFramesPerSecond)
        return Demand(frame, due, nextGeneration).also { pending = it }
    }

fun markPresented(frame: Int, nowNanos: Long)

    fun markPresented(frame: Int, nowNanos: Long) {
        val demand = checkNotNull(pending) { "no paced frame is pending" }
        check(demand.frame == frame) { "presented frame $frame while ${demand.frame} was pending" }
        presentedFrame = frame
        val interval = intervalNanos(checkNotNull(cadence).rate)
        // A normal render-frame quantization miss must not slow the whole movie: retain the ideal
        // phase. A genuine underrun of a full interval resets it, avoiding a burst of catch-up frames.
        lastPresentationDeadline = if (nowNanos - demand.notBeforeNanos >= interval) nowNanos
        else demand.notBeforeNanos
        pending = null
    }

fun reset()

    fun reset() {
        revision = null
        cadence = null
        pending = null
        presentedFrame = null
        lastPresentationDeadline = Long.MIN_VALUE
        nextGeneration++
    }

    private fun adjacent(frame: Int, rate: Double, loop: Boolean): Int? {
        val step = if (rate < 0.0) -1 else 1
        val next = frame + step
        if (next in 0 until frameCount) return next
        return if (loop) Math.floorMod(next, frameCount) else null
    }

    private fun intervalNanos(rate: Double): Long {
        val magnitude = abs(rate)
        check(magnitude > 0.0)
        return (1e9 / magnitude).toLong().coerceAtLeast(1L)
    }
}