Skip to content

VideoTexture.kt

src/jvmMain/kotlin/afield/graphics/texture/video/VideoTexture.kt · 974 lines

Plays a video file into Vulkan textures that a shader can sample, with VideoTexture.load, VideoTexture.play, VideoTexture.pause, VideoTexture.seek and VideoTexture.unload.

Frames come from VideoDecoder as P010 (luma and chroma planes) for display video, or as RGBA8 for movies whose channels carry data. Timing comes from a PlaybackClock: an internal SystemClock by default, or an AudioClock for audio-locked playback.

package afield.graphics.texture.video

import afield.graphics.vulkan.device.vkLayout
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.buffers.VulkanBuffer
import afield.graphics.vulkan.device.CommandBuffer
import afield.document.context.fromContext
import afield.graphics.texture.HasImageView
import afield.graphics.texture.HasImageViewArray
import afield.graphics.texture.Image
import afield.graphics.texture.ImageView
import afield.graphics.texture.vkFormatBytesPerPixel
import afield.app.diagnostics.logging.lprint
import afield.app.diagnostics.logging.lprintln
import afield.app.server.Browsable
import afield.app.server.HasBrowsable
import org.lwjgl.system.MemoryStack
import org.lwjgl.system.MemoryUtil
import org.lwjgl.vulkan.*
import java.nio.ByteBuffer
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.locks.LockSupport
import kotlin.concurrent.thread

class VideoTexture

VideoTexture manages video playback with P010 format support. Maintains configurable texture slot arrays for luma and chroma planes in semi-planar format. Supports load, play, scrub, and unload operations while reusing Vulkan resources when dimensions match.

@param renderer The Vulkan renderer @param clock Optional external clock for synchronization (e.g., AudioClock for A/V sync). If null, uses internal SystemClock which may drift over long durations. @param backgroundDecode Decode and RGB conversion on a worker, leaving only the Vulkan upload in the render callback. Useful for large data-bearing atlases; false preserves the original synchronous behavior.

class VideoTexture(
    private val renderer: Renderer = fromContext(),
    private val clock: PlaybackClock? = null,
    private val textureSlots: Int = 2,
    /** P010 for display video; RGBA8 for movies whose RGB channels carry numeric data. */
    val pixelFormat: VideoDecodeFormat = VideoDecodeFormat.P010,
    /**
     * Decode and colour-convert on a worker thread, leaving the render callback only the Vulkan
     * upload; with it off, [update] decodes inline on whichever thread calls it. It also enables
     * [prepareFrame], and opens a second decoder so that a short loop does not pay for an
     * end-to-start seek each time around.
     */
    val backgroundDecode: Boolean = false
) : HasImageViewArray, afield.graphics.vulkan.Target, HasBrowsable {

Optional sampled-image indirection. A module may keep this VideoTexture as its stable descriptor provider while a frame-set presenter atomically changes which already-loaded VideoTexture its shaders see. Decoding and uploading still happen on the source texture itself.

    @Volatile
    private var sampledSource: VideoTexture? = null

fun sampleFrom(source: VideoTexture?)

    fun sampleFrom(source: VideoTexture?) {
        val next = source?.takeUnless { it === this }
        if (next != null) {
            require(next.pixelFormat == pixelFormat) {
                "cannot sample ${next.pixelFormat} through a $pixelFormat VideoTexture"
            }
            require(next.width == width && next.height == height) {
                "sampled video dimensions ${next.width}x${next.height} do not match ${width}x${height}"
            }
        }
        sampledSource = next
    }

val sampledFrom: VideoTexture get() = sampledSource ?: this

The texture whose images imageViewArray hands out: this one, unless sampleFrom has pointed it at another already-loaded VideoTexture of matching format and size. Only the descriptor array follows the redirection, so getLumaTexture, timestamp and the rest still report this texture's own state.

override fun browsable(): Browsable?

Inspector support: expose state + version tied to timestamp

    override fun browsable(): Browsable? = object : Browsable {
        override fun heading(): String = "VideoTexture"
        override fun dir(): Map<String, Any?>? = null
        override fun html(): String {
            fun esc(s: String) = s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
            val video = esc(currentVideoPath ?: "(none)")
            val ts = "%.2fs".format(currentTimestamp)
            val size = if (videoWidth > 0) "${videoWidth}x${videoHeight}" else "(unloaded)"
            val slotVal = currentSlot.get().toString()
            val state = if (isPlaying) "playing" else "paused"
            val pending = if (pendingFrameRefresh) " · pending" else ""
            return """
                <div style="font-family:inherit;color:#ddd;padding:6px 8px;">
                  <div style="display:grid;grid-template-columns:120px 1fr;row-gap:4px;column-gap:10px;align-items:baseline;">
                    <div style="text-align:right;color:#8a8a8a;letter-spacing:0.6px;font-size:12px;">video</div><div style="color:#d0d0d0;white-space:pre-wrap;">$video</div>
                    <div style="text-align:right;color:#8a8a8a;letter-spacing:0.6px;font-size:12px;">state</div><div style="color:#d0d0d0;">$state$pending</div>
                    <div style="text-align:right;color:#8a8a8a;letter-spacing:0.6px;font-size:12px;">timestamp</div><div style="color:#d0d0d0;">$ts</div>
                    <div style="text-align:right;color:#8a8a8a;letter-spacing:0.6px;font-size:12px;">size</div><div style="color:#d0d0d0;">$size</div>
                    <div style="text-align:right;color:#8a8a8a;letter-spacing:0.6px;font-size:12px;">slot</div><div style="color:#d0d0d0;">$slotVal</div>
                  </div>
                </div>
            """.trimIndent()
        }
        override fun version(): Long =
            (currentTimestamp * 1000).toLong() +
                (if (isPlaying) 1 else 0) +
                (if (pendingFrameRefresh) 2 else 0) +
                (currentSlot.get().toLong() shl 2)
    }

Current video state

    private var decoder: VideoDecoder? = null
    private var loopDecoder: VideoDecoder? = null
    private var currentVideoPath: String? = null
    private var isPlaying = false

Internal clock used when no external clock is provided

    private val internalClock = SystemClock()

Active clock (external if provided, internal otherwise)

    private val activeClock: PlaybackClock
        get() = clock ?: internalClock

    private var currentTimestamp = 0.0
    private var lastFrameTimestamp = 0.0
    @Volatile private var uploadedFrame = false

Video dimensions

    private var videoWidth = 0
    private var videoHeight = 0

Texture slot arrays for luma and chroma

    private var lumaTextures = arrayOfNulls<VideoPlaneTexture>(textureSlots)
    private var chromaTextures = arrayOfNulls<VideoPlaneTexture>(textureSlots)
    private var rgbaTextures = arrayOfNulls<VideoPlaneTexture>(textureSlots)

Current slot index - indicates most recent frame

    private val currentSlot = AtomicInteger(0)

When true, allow a one-off update while paused to reflect a scrubbed position

    private var pendingFrameRefresh = false

    private data class DecodeRequest(val generation: Long, val timestamp: Double, val exactFrame: Long? = null)
    private data class DecodedFrame(val serial: Long, val frame: VideoFrame)

    private val decodeGeneration = AtomicLong()
    private val decodeSerial = AtomicLong()
    private val requestedDecode = AtomicReference<DecodeRequest?>()
    private val pendingDecode = AtomicReference<DecodeRequest?>()
    private val decodedFrame = AtomicReference<DecodedFrame?>()
    private val decodedFrameLock = Any()
    private var decodeWorker: Thread? = null
    private var loopResetWorker: Thread? = null
    @Volatile private var decodeWorkerRunning = false
    @Volatile private var decodeFailure: Throwable? = null

var backgroundDecodeMicros: Long

Microseconds the worker's most recent VideoDecoder.decodeNextFrame took, for watching whether decoding is keeping up. It reports the last frame rather than an average, and only backgroundDecode playback writes it.

    @Volatile
    var backgroundDecodeMicros: Long = 0
        private set
    private var lastRequestedFrame = Long.MIN_VALUE
    private var seenDecodedSerial = -1L

Format constants for P010

    private val lumaFormat = VK10.VK_FORMAT_R16_UNORM  // 16-bit luma
    private val chromaFormat = VK10.VK_FORMAT_R16G16_UNORM  // 16-bit interleaved UV

init

    init {
        require(textureSlots > 0) { "textureSlots must be at least 1" }
    }

fun load(videoPath: String): Boolean

Load a video file. If dimensions match the previously loaded video, reuses Vulkan resources.

body — 100 lines, from line 191
    fun load(videoPath: String): Boolean {
        // If loading the same video, just seek to start
        if (videoPath == currentVideoPath && decoder != null) {
            seek(0.0)
            return true
        }

        // Create new decoder
        val newDecoder = VideoDecoder(videoPath, pixelFormat)
        if (!newDecoder.open()) {
            lprintln("error,video", "Failed to open video: $videoPath")
            return false
        }
        val newLoopDecoder = if (backgroundDecode) VideoDecoder(videoPath, pixelFormat).also {
            if (!it.open()) {
                newDecoder.close()
                lprintln("error,video", "Failed to open loop-ahead decoder: $videoPath")
                return false
            }
        } else null

        val newWidth = newDecoder.width
        val newHeight = newDecoder.height

        // Check if we can reuse existing textures (same dimensions)
        val texturesAllocated = when (pixelFormat) {
            VideoDecodeFormat.P010 -> lumaTextures.all { it != null } && chromaTextures.all { it != null }
            VideoDecodeFormat.RGBA8 -> rgbaTextures.all { it != null }
        }
        val canReuseTextures = (newWidth == videoWidth && newHeight == videoHeight && texturesAllocated)

        // Close old decoder if exists. A background decoder owns FFmpeg exclusively while running.
        stopDecodeWorker()
        decoder?.close()
        loopDecoder?.close()
        decoder = newDecoder
        loopDecoder = newLoopDecoder
        currentVideoPath = videoPath

        if (canReuseTextures) {
            lprintln("info,video", "Reusing existing textures for video: $videoPath (${newWidth}x${newHeight})")
        } else {
            lprintln("info,video", "Creating new textures for video: $videoPath (${newWidth}x${newHeight})")

            // Clean up old textures if they exist
            cleanupTextures()

            // Update dimensions
            videoWidth = newWidth
            videoHeight = newHeight

            // Create the upload textures for the selected decode contract.
            for (i in 0 until textureSlots) {
                when (pixelFormat) {
                    VideoDecodeFormat.P010 -> {
                        lumaTextures[i] = VideoPlaneTexture(
                            renderer = renderer,
                            width = videoWidth,
                            height = videoHeight,
                            format = lumaFormat,
                            name = "video_luma_$i"
                        )
                        chromaTextures[i] = VideoPlaneTexture(
                            renderer = renderer,
                            width = videoWidth / 2,
                            height = videoHeight / 2,
                            format = chromaFormat,
                            name = "video_chroma_$i"
                        )
                    }
                    VideoDecodeFormat.RGBA8 -> rgbaTextures[i] = VideoPlaneTexture(
                        renderer = renderer,
                        width = videoWidth,
                        height = videoHeight,
                        format = VK10.VK_FORMAT_R8G8B8A8_UNORM,
                        name = "video_rgba_$i"
                    )
                }
            }
        }

        currentTimestamp = 0.0
        lastFrameTimestamp = 0.0
        uploadedFrame = false
        uploadedFrameIndex = -1
        currentSlot.set(0)
        pendingFrameRefresh = true
        lastRequestedFrame = Long.MIN_VALUE
        seenDecodedSerial = -1L
        clearDecodedFrame()

        // Reset internal clock when loading a new video
        if (clock == null) {
            internalClock.reset()
        }

        if (backgroundDecode) startDecodeWorker(newDecoder, checkNotNull(newLoopDecoder))

        return true
    }

fun play()

Start or resume playback.

    fun play() {
        if (decoder != null) {
            isPlaying = true

            // If using internal clock, start it
            if (clock == null) {
                internalClock.play()
            }
        }
    }

fun pause()

Pause playback.

    fun pause() {
        isPlaying = false

        // If using internal clock, pause it
        if (clock == null) {
            internalClock.pause()
        }
    }

fun seek(timestampSeconds: Double): Boolean

Seek to a specific timestamp in seconds.

    fun seek(timestampSeconds: Double): Boolean {
        lprint("info,video", "Seeking to $timestampSeconds")
        decoder?.let { dec ->
            if (backgroundDecode) {
                currentTimestamp = timestampSeconds
                lastFrameTimestamp = timestampSeconds
                lastRequestedFrame = Long.MIN_VALUE
                pendingFrameRefresh = true
                if (clock == null) internalClock.seek(timestampSeconds)
                requestBackgroundDecode(timestampSeconds)
                return true
            }
            if (dec.seek(timestampSeconds)) {
                currentTimestamp = timestampSeconds
                lastFrameTimestamp = timestampSeconds

                // If using internal clock, seek it
                if (clock == null) {
                    internalClock.seek(timestampSeconds)
                }

                if (!isPlaying) {
                    pendingFrameRefresh = true
                }
                return true
            }
        }
        return false
    }

fun update(commandBuffer: CommandBuffer): Boolean

Update the video texture. Call this every frame to decode and upload new frames when playing. Returns true if a new frame was uploaded.

When using an external clock (e.g., AudioClock), this method will sync video frames to the clock's time. When using internal clock, it advances based on wall-clock time (may drift over long durations).

body — 115 lines, from line 358
    fun update(commandBuffer: CommandBuffer): Boolean {
        val activeDecoder = decoder ?: return false
        val allowWhilePaused = pendingFrameRefresh
        val shouldUpdate = isPlaying || allowWhilePaused

        if (!shouldUpdate) {
            return false
        }

        // Get target timestamp from the active clock
        val targetTimestamp = activeClock.getCurrentTime()

        if (backgroundDecode) {
            return updateFromBackground(commandBuffer, activeDecoder, targetTimestamp)
        }

        // Check if we need to decode a new frame
        // We decode if:
        // 1. We're significantly behind the clock (more than one frame)
        // 2. We're paused and need a refresh
        val frameDuration = if (activeDecoder.fps > 0) 1.0 / activeDecoder.fps else 0.033
        val delta = targetTimestamp - lastFrameTimestamp
        // An external timeline can scrub or loop. Decoding forward from the old cursor cannot reach a
        // time behind it, and walking hundreds of frames after a large jump is the wrong latency.
        val discontinuity = delta < -frameDuration * 0.5 || delta > frameDuration * 12.0
        if (discontinuity) {
            if (!activeDecoder.seek(targetTimestamp.coerceAtLeast(0.0))) return false
            lastFrameTimestamp = targetTimestamp - frameDuration
        }
        val needsNewFrame = discontinuity || (targetTimestamp - lastFrameTimestamp) >= frameDuration || allowWhilePaused

        if (!needsNewFrame) {
            return false
        }

        // Decode frames until we reach or pass the target timestamp
        var decodedFrame: VideoFrame? = null
        var attempts = 0
        val maxAttempts = 10  // Prevent infinite loops

        while (attempts < maxAttempts) {
            val frame = activeDecoder.decodeNextFrame() ?: break

            decodedFrame?.close()
            decodedFrame = frame
            attempts++

            // If this frame is at or past the target time, use it
            if (frame.timestamp >= targetTimestamp) {
                break
            }

            // If we're more than 2 frames ahead, we're seeking or lagging - use this frame
            if (targetTimestamp - frame.timestamp > frameDuration * 2) {
                // Skip frames when catching up
                continue
            }

            // Frame is close enough to target, use it
            break
        }

        lprint("info, video", "decoded frame in ${attempts} attempts")

        val frame = decodedFrame ?: return false
        try {
            // Check if we're past the video duration
            if (frame.timestamp >= activeDecoder.duration) {
                // Loop back to start
                activeDecoder.seek(0.0)
                lastFrameTimestamp = 0.0

                // Reset internal clock if we're using it
                if (clock == null) {
                    internalClock.seek(0.0)
                }

                return false
            }

            // Switch to next slot
            val nextSlot = (currentSlot.get() + 1) % textureSlots

            when (pixelFormat) {
                VideoDecodeFormat.P010 -> {
                    val lumaTexture = lumaTextures[nextSlot] ?: return false
                    val chromaTexture = chromaTextures[nextSlot] ?: return false
                    lumaTexture.upload(commandBuffer, frame.plane0)
                    chromaTexture.upload(commandBuffer, frame.plane1)
                }
                VideoDecodeFormat.RGBA8 -> {
                    val rgbaTexture = rgbaTextures[nextSlot] ?: return false
                    rgbaTexture.upload(commandBuffer, frame.plane0)
                }
            }

            // Update current slot
            currentSlot.set(nextSlot)

            // Track the timestamp of this frame
            lastFrameTimestamp = frame.timestamp
            currentTimestamp = frame.timestamp
            uploadedFrame = true
            uploadedFrameIndex = if (fps > 0) VideoFrameIndex.decoded(frame.timestamp, fps) else -1

            // If we were paused and refreshing after a scrub, stop the temporary refresh
            if (!isPlaying && pendingFrameRefresh) {
                pendingFrameRefresh = false
            }

            return true
        } finally {
            frame.close()
        }
    }

fun getLumaTexture(): HasImageView?

Get the current luma texture.

    fun getLumaTexture(): HasImageView? {
        return lumaTextures[currentSlot.get()]
    }

fun getChromaTexture(): HasImageView?

Get the current chroma texture.

    fun getChromaTexture(): HasImageView? {
        return chromaTextures[currentSlot.get()]
    }

override val imageViewArray: List<HasImageView>

Get both current textures as an array (luma at index 0, chroma at index 1).

    override val imageViewArray: List<HasImageView>
        get() {
            sampledSource?.let { return it.imageViewArray }
            val slot = currentSlot.get()
            return when (pixelFormat) {
                VideoDecodeFormat.P010 -> listOfNotNull(lumaTextures[slot], chromaTextures[slot])
                VideoDecodeFormat.RGBA8 -> listOfNotNull(rgbaTextures[slot])
            }
        }

val width: Int get() = videoWidth

Width in pixels of the loaded video, and 0 with nothing loaded. cleanup returns it to 0; unload leaves it, because the Vulkan textures stay allocated for the next video of the same size.

val height: Int get() = videoHeight

Height in pixels of the loaded video, and 0 with nothing loaded, on width's terms.

val duration: Double get() = decoder?.duration ?: 0.0

Length of the loaded video in seconds, read from VideoDecoder.duration, and 0.0 with nothing loaded.

val fps: Double get() = decoder?.fps ?: 0.0

Frame rate the loaded video declares, and 0.0 with nothing loaded. update and prepareFrame use it to turn a clock time into a frame number, and prepareFrame refuses to run while it is 0.

val timestamp: Double get() = currentTimestamp

Presentation time in seconds of the frame most recently uploaded, which is not the time the clock is asking for. seek moves it to the requested position before any frame has been decoded there.

val playing: Boolean get() = isPlaying

True between play and pause. play does nothing without a loaded video, and running off the end of the video loops back to the start rather than clearing this.

val hasFrame: Boolean get() = uploadedFrame

True after at least one decoded frame has reached its Vulkan image.

var uploadedFrameIndex: Long

Source-frame identity of the image selected for the next draw, not the requested clock.

    var uploadedFrameIndex: Long = -1
        private set

fun prepareFrame(frameIndex: Long)

Prepare a CFR source frame off-thread without uploading it. Do not also schedule update.

    fun prepareFrame(frameIndex: Long) {
        require(backgroundDecode) { "prepareFrame requires backgroundDecode=true" }
        require(frameIndex >= 0 && fps > 0) { "load a video and request a non-negative frame" }
        decodeFailure?.let { throw IllegalStateException("background video decode failed", it) }
        if (requestedDecode.get()?.exactFrame == frameIndex) return
        requestBackgroundDecode(frameIndex / fps, frameIndex)
    }

fun isFrameReady(frameIndex: Long): Boolean

    fun isFrameReady(frameIndex: Long): Boolean {
        decodeFailure?.let { throw IllegalStateException("background video decode failed", it) }
        return synchronized(decodedFrameLock) {
            val ready = decodedFrame.get() ?: return@synchronized false
            VideoFrameIndex.decoded(ready.frame.timestamp, fps) == frameIndex
        }
    }

fun uploadPreparedFrame(commandBuffer: CommandBuffer, frameIndex: Long): Boolean

Record this exact image's upload before the same draw as its matching geometry. Never waits.

    fun uploadPreparedFrame(commandBuffer: CommandBuffer, frameIndex: Long): Boolean {
        decodeFailure?.let { throw IllegalStateException("background video decode failed", it) }
        return synchronized(decodedFrameLock) {
            val ready = decodedFrame.get() ?: return@synchronized false
            if (VideoFrameIndex.decoded(ready.frame.timestamp, fps) != frameIndex) return@synchronized false
            if (seenDecodedSerial == ready.serial) return@synchronized uploadedFrameIndex == frameIndex
            uploadDecodedFrame(commandBuffer, ready)
        }
    }

fun unload()

Unload the current video and free resources.

    fun unload() {
        pause()
        stopDecodeWorker()
        decoder?.close()
        loopDecoder?.close()
        decoder = null
        loopDecoder = null
        currentVideoPath = null
        currentTimestamp = 0.0
        uploadedFrame = false
        uploadedFrameIndex = -1
        // Note: We keep the Vulkan textures allocated for potential reuse
    }

fun cleanup()

Clean up all resources including Vulkan textures.

    fun cleanup() {
        unload()
        cleanupTextures()
    }

    private fun cleanupTextures() {
        for (i in 0 until textureSlots) {
            lumaTextures[i]?.cleanup()
            lumaTextures[i] = null
            chromaTextures[i]?.cleanup()
            chromaTextures[i] = null
            rgbaTextures[i]?.cleanup()
            rgbaTextures[i] = null
        }
        videoWidth = 0
        videoHeight = 0
    }

override fun render(command: CommandBuffer, index: Int)

body — 199 lines, from line 606
    override fun render(command: CommandBuffer, index: Int) {
        update(command)
    }

    private fun updateFromBackground(
        commandBuffer: CommandBuffer,
        activeDecoder: VideoDecoder,
        targetTimestamp: Double
    ): Boolean {
        decodeFailure?.let { throw IllegalStateException("background video decode failed", it) }
        val frameDuration = if (activeDecoder.fps > 0) 1.0 / activeDecoder.fps else 0.033
        val targetFrame = VideoFrameIndex.requested(targetTimestamp, 1.0 / frameDuration)
        if (targetFrame != lastRequestedFrame) {
            lastRequestedFrame = targetFrame
            requestBackgroundDecode(targetFrame * frameDuration)
        }

        return synchronized(decodedFrameLock) {
            val ready = decodedFrame.get() ?: return@synchronized false
            if (ready.serial == seenDecodedSerial) return@synchronized false
            uploadDecodedFrame(commandBuffer, ready)
        }
    }

    private fun uploadDecodedFrame(commandBuffer: CommandBuffer, ready: DecodedFrame): Boolean {
        val frame = ready.frame
        val nextSlot = (currentSlot.get() + 1) % textureSlots
        when (pixelFormat) {
            VideoDecodeFormat.P010 -> {
                val lumaTexture = lumaTextures[nextSlot] ?: return false
                val chromaTexture = chromaTextures[nextSlot] ?: return false
                lumaTexture.upload(commandBuffer, frame.plane0)
                chromaTexture.upload(commandBuffer, frame.plane1)
            }
            VideoDecodeFormat.RGBA8 -> {
                val rgbaTexture = rgbaTextures[nextSlot] ?: return false
                rgbaTexture.upload(commandBuffer, frame.plane0)
            }
        }
        seenDecodedSerial = ready.serial
        currentSlot.set(nextSlot)
        lastFrameTimestamp = frame.timestamp
        currentTimestamp = frame.timestamp
        uploadedFrame = true
        uploadedFrameIndex = if (fps > 0) VideoFrameIndex.decoded(frame.timestamp, fps) else -1
        pendingFrameRefresh = false
        return true
    }

    private fun requestBackgroundDecode(timestamp: Double, exactFrame: Long? = null) {
        val request = DecodeRequest(decodeGeneration.incrementAndGet(), timestamp.coerceAtLeast(0.0), exactFrame)
        requestedDecode.set(request)
        pendingDecode.set(request)
        LockSupport.unpark(decodeWorker)
    }

    private fun startDecodeWorker(activeDecoder: VideoDecoder, loopDecoder: VideoDecoder) {
        check(!decodeWorkerRunning)
        decodeFailure = null
        requestedDecode.set(null)
        pendingDecode.set(null)
        decodeWorkerRunning = true
        decodeWorker = thread(name = "video-decode-${pixelFormat.name.lowercase()}", isDaemon = true) {
            runDecodeWorker(activeDecoder, loopDecoder)
        }
    }

    private fun runDecodeWorker(initialDecoder: VideoDecoder, initialLoopDecoder: VideoDecoder) {
        var activeDecoder = initialDecoder
        var standbyDecoder = initialLoopDecoder
        val standbyReady = AtomicBoolean(false)
        rewindLoopDecoder(initialLoopDecoder, standbyReady, warmUp = true)
        var decoderTimestamp = Double.NaN
        try {
            while (decodeWorkerRunning) {
                val request = pendingDecode.getAndSet(null)
                if (request == null) {
                    LockSupport.parkNanos(2_000_000L)
                    continue
                }
                val frameDuration = if (activeDecoder.fps > 0) 1.0 / activeDecoder.fps else 0.033
                var rewindAfterPublish: VideoDecoder? = null
                // The expensive end-to-start FFmpeg seek used to puncture every short looping cue.
                // Alternate two already-open decoders instead: the standby is sitting at frame zero,
                // while the exhausted decoder rewinds on its own thread during the next loop.
                if (request.exactFrame == 0L && decoderTimestamp.isFinite() &&
                    decoderTimestamp > frameDuration * 2.0 && standbyReady.compareAndSet(true, false)) {
                    val exhausted = activeDecoder
                    activeDecoder = standbyDecoder
                    standbyDecoder = exhausted
                    rewindAfterPublish = standbyDecoder
                    decoderTimestamp = -frameDuration
                }
                val already = synchronized(decodedFrameLock) { decodedFrame.get() }
                if (request.exactFrame != null && already != null &&
                    VideoFrameIndex.decoded(already.frame.timestamp, activeDecoder.fps) == request.exactFrame) continue
                val delta = request.timestamp - decoderTimestamp
                // Forward playback is deliberately sequential. A long-GOP movie may seek back to
                // frame zero anyway, and repeatedly restarting that decode is both slower and prone
                // to starving. Backward motion (including loop wrap) still seeks immediately.
                if (!decoderTimestamp.isFinite() || delta < -frameDuration * 0.5 ||
                    (request.exactFrame != null && delta <= frameDuration * 0.5)) {
                    check(activeDecoder.seek(request.timestamp)) { "could not seek video to ${request.timestamp}s" }
                    decoderTimestamp = request.timestamp - frameDuration
                }

                var attempts = 0
                var reached = false
                while (decodeWorkerRunning && attempts < 48) {
                    if (request.exactFrame != null && requestedDecode.get()?.generation != request.generation) break
                    val latestTarget = requestedDecode.get()?.timestamp ?: request.timestamp
                    if (decoderTimestamp.isFinite() && latestTarget < decoderTimestamp - frameDuration * 0.5) break
                    val frameStarted = System.nanoTime()
                    val frame = activeDecoder.decodeNextFrame()
                    if (frame == null) {
                        check(request.exactFrame == null) { "video ended before requested source frame ${request.exactFrame}" }
                        break
                    }
                    decoderTimestamp = frame.timestamp
                    attempts++
                    backgroundDecodeMicros = (System.nanoTime() - frameStarted) / 1_000L
                    var published = false
                    try {
                        if (request.exactFrame != null) {
                            val frameIndex = VideoFrameIndex.decoded(frame.timestamp, activeDecoder.fps)
                            check(frameIndex <= request.exactFrame) { "video skipped requested frame ${request.exactFrame}, got $frameIndex" }
                            if (frameIndex == request.exactFrame) {
                                publishDecodedFrame(frame)
                                published = true
                                reached = true
                                break
                            }
                        } else {
                            publishDecodedFrame(frame)
                            published = true
                            if (frame.timestamp + frameDuration * 0.5 >= latestTarget) break
                        }
                    } finally {
                        if (!published) frame.close()
                    }
                }
                // Long-GOP seeks can need more than one bounded decode pass. Keep the exact request
                // alive, but never overwrite a newer seek/loop request.
                if (request.exactFrame != null && !reached && requestedDecode.get()?.generation == request.generation) {
                    pendingDecode.compareAndSet(null, request)
                }
                // The decoder stopped being active as soon as we swapped it out. Rewind it even if
                // an explicit seek superseded frame zero before publication, otherwise one unlucky
                // scrub at the loop boundary permanently loses the prepared standby.
                if (rewindAfterPublish != null) {
                    rewindLoopDecoder(checkNotNull(rewindAfterPublish), standbyReady)
                }
            }
        } catch (t: Throwable) {
            if (decodeWorkerRunning) decodeFailure = t
        } finally {
            decodeWorkerRunning = false
        }
    }

    private fun rewindLoopDecoder(value: VideoDecoder, ready: AtomicBoolean, warmUp: Boolean = false) {
        loopResetWorker?.join(5_000L)
        check(loopResetWorker?.isAlive != true) { "previous loop decoder is still rewinding" }
        loopResetWorker = thread(name = "video-loop-rewind-${pixelFormat.name.lowercase()}", isDaemon = true) {
            // Opening FFmpeg is not enough to pay its first-decode costs. Prime the initially cold
            // standby during the first loop, then return it to frame zero before advertising it.
            if (warmUp) value.decodeNextFrame()?.close()
            if (value.seek(0.0) && decodeWorkerRunning) ready.set(true)
            else if (decodeWorkerRunning) lprintln("warning,video", "Could not pre-rewind loop decoder")
        }
    }

    private fun stopDecodeWorker() {
        decodeWorkerRunning = false
        LockSupport.unpark(decodeWorker)
        decodeWorker?.interrupt()
        decodeWorker?.join(5_000L)
        check(decodeWorker?.isAlive != true) { "video decode worker did not stop" }
        decodeWorker = null
        loopResetWorker?.join(5_000L)
        check(loopResetWorker?.isAlive != true) { "video loop rewind worker did not stop" }
        loopResetWorker = null
        requestedDecode.set(null)
        pendingDecode.set(null)
        clearDecodedFrame()
    }

    private fun publishDecodedFrame(frame: VideoFrame) {
        synchronized(decodedFrameLock) {
            decodedFrame.getAndSet(DecodedFrame(decodeSerial.incrementAndGet(), frame))?.frame?.close()
        }
    }

    private fun clearDecodedFrame() {
        synchronized(decodedFrameLock) {
            decodedFrame.getAndSet(null)?.frame?.close()
        }
    }
}

Internal class representing a single video plane texture (luma or chroma).

source — 165 lines, from line 809
private class VideoPlaneTexture(
    private val renderer: Renderer,
    val width: Int,
    val height: Int,
    val format: Int,
    val name: String
) : HasImageView {

    private val image: Image
    override val imageView: ImageView
    private val stagingBuffer: VulkanBuffer
    private var currentLayout = VK10.VK_IMAGE_LAYOUT_UNDEFINED

    init {
        val bytesPerPixel = vkFormatBytesPerPixel(format, "$name plane")
        val imageSize = width * height * bytesPerPixel

        // Create staging buffer for uploads
        stagingBuffer = VulkanBuffer(
            renderer,
            imageSize.toLong(),
            VK10.VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
            VK10.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT or VK10.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
        )

        // Create image
        val imageData = Image.ImageData()
            .width(width)
            .height(height)
            .format(format)
            .usage(VK10.VK_IMAGE_USAGE_TRANSFER_DST_BIT or VK10.VK_IMAGE_USAGE_SAMPLED_BIT)
            .mipLevels(1)

        image = Image(renderer.device, imageData)

        // Create image view
        val imageViewData = ImageView.ImageViewData()
            .format(format)
            .aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT)
            .mipLevels(1)

        imageView = ImageView(renderer.device, image, imageViewData)
    }

    /**
     * Upload data to this texture plane.
     */
    fun upload(cmd: CommandBuffer, data: ByteBuffer) {
        // Create a duplicate to avoid modifying the caller's buffer position
        val dataCopy = data.duplicate()

        // Copy data to staging buffer
        val mappedMemory = stagingBuffer.map()
        val buffer = MemoryUtil.memByteBuffer(mappedMemory, dataCopy.remaining())
        buffer.put(dataCopy)
        stagingBuffer.unmap()

        MemoryStack.stackPush().use { stack ->
            // Transition to transfer destination
            recordImageTransition(stack, cmd, currentLayout, VK10.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL)

            // Copy buffer to image
            recordCopyBuffer(stack, cmd)

            // Transition to shader read
            recordImageTransition(
                stack,
                cmd,
                VK10.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
                VK10.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
            )
        }

        currentLayout = VK10.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
    }

    private fun recordCopyBuffer(stack: MemoryStack, cmd: CommandBuffer) {
        val region = VkBufferImageCopy.calloc(1, stack)
            .bufferOffset(0)
            .bufferRowLength(0)
            .bufferImageHeight(0)
            .imageSubresource { it: VkImageSubresourceLayers ->
                it.aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT)
                    .mipLevel(0)
                    .baseArrayLayer(0)
                    .layerCount(1)
            }
            .imageOffset { it: VkOffset3D -> it.x(0).y(0).z(0) }
            .imageExtent { it: VkExtent3D ->
                it.width(width).height(height).depth(1)
            }

        VK10.vkCmdCopyBufferToImage(
            cmd.vkCommandBuffer,
            stagingBuffer.buffer,
            image.vkImage,
            vkLayout(VK10.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL),
            region
        )
    }

    private fun recordImageTransition(
        stack: MemoryStack,
        cmd: CommandBuffer,
        oldLayout: Int,
        newLayout: Int
    ) {
        val barrier = VkImageMemoryBarrier.calloc(1, stack)
            .sType(VK10.VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER)
            .oldLayout(vkLayout(oldLayout))
            .newLayout(vkLayout(newLayout))
            .srcQueueFamilyIndex(VK10.VK_QUEUE_FAMILY_IGNORED)
            .dstQueueFamilyIndex(VK10.VK_QUEUE_FAMILY_IGNORED)
            .image(image.vkImage)
            .subresourceRange { it: VkImageSubresourceRange ->
                it.aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT)
                    .baseMipLevel(0)
                    .levelCount(1)
                    .baseArrayLayer(0)
                    .layerCount(1)
            }

        val srcStage: Int
        val srcAccessMask: Int
        val dstAccessMask: Int
        val dstStage: Int

        when {
            oldLayout == VK10.VK_IMAGE_LAYOUT_UNDEFINED && newLayout == VK10.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL -> {
                srcStage = VK10.VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT
                srcAccessMask = 0
                dstStage = VK10.VK_PIPELINE_STAGE_TRANSFER_BIT
                dstAccessMask = VK10.VK_ACCESS_TRANSFER_WRITE_BIT
            }
            oldLayout == VK10.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL && newLayout == VK10.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL -> {
                // Video can drive vertex projection and compute forces as well as fragment colour.
                srcStage = VK10.VK_PIPELINE_STAGE_ALL_COMMANDS_BIT
                srcAccessMask = VK10.VK_ACCESS_SHADER_READ_BIT
                dstStage = VK10.VK_PIPELINE_STAGE_TRANSFER_BIT
                dstAccessMask = VK10.VK_ACCESS_TRANSFER_WRITE_BIT
            }
            oldLayout == VK10.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL && newLayout == VK10.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL -> {
                srcStage = VK10.VK_PIPELINE_STAGE_TRANSFER_BIT
                srcAccessMask = VK10.VK_ACCESS_TRANSFER_WRITE_BIT
                dstStage = VK10.VK_PIPELINE_STAGE_ALL_COMMANDS_BIT
                dstAccessMask = VK10.VK_ACCESS_SHADER_READ_BIT
            }
            else -> throw RuntimeException("Unsupported layout transition: $oldLayout -> $newLayout")
        }

        barrier.srcAccessMask(srcAccessMask)
        barrier.dstAccessMask(dstAccessMask)

        VK10.vkCmdPipelineBarrier(cmd.vkCommandBuffer, srcStage, dstStage, 0, null, null, barrier)
    }

    fun cleanup() {
        imageView.cleanup()
        image.cleanup()
        stagingBuffer.cleanup()
    }



}