VideoDecoder.kt
src/jvmMain/kotlin/afield/graphics/texture/video/VideoDecoder.kt · 502 lines
Decodes video frames with FFmpeg into P010 or RGBA8 buffers (VideoFrame) for VideoTexture.
VideoDecodeFormat picks the output: P010 for ordinary display, RGBA8 when the R/G/B channels
are values that 4:2:0 chroma subsampling would destroy. VideoFrameTest covers VideoFrame
buffer release.
package afield.graphics.texture.video
import afield.app.diagnostics.logging.lprintln
import org.bytedeco.ffmpeg.avcodec.AVCodecContext
import org.bytedeco.ffmpeg.avcodec.AVPacket
import org.bytedeco.ffmpeg.avformat.AVFormatContext
import org.bytedeco.ffmpeg.avutil.AVFrame
import org.bytedeco.ffmpeg.global.avcodec.*
import org.bytedeco.ffmpeg.global.avformat.*
import org.bytedeco.ffmpeg.global.avutil.*
import org.bytedeco.ffmpeg.global.swscale.*
import org.bytedeco.ffmpeg.swscale.SwsContext
import org.bytedeco.javacpp.DoublePointer
import org.bytedeco.javacpp.PointerPointer
import java.nio.ByteBuffer
import java.util.ArrayDeque
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.min
class VideoDecoder
VideoDecoder wraps FFmpeg functionality to decode video frames. Converts decoded frames either to P010 for ordinary video display or to tightly-packed RGBA8 for data-bearing RGB movies. The latter matters for atlases whose R/G/B channels are values rather than colour: routing those through 4:2:0 YUV would irreversibly subsample the channels.
class VideoDecoder(
private val videoPath: String,
/**
* Pixel layout [decodeNextFrame] produces: P010 for display video, RGBA8 for a movie whose
* R/G/B channels carry numbers rather than colour. Fixed at construction, because [open]
* configures the FFmpeg scaler for it.
*/
val outputFormat: VideoDecodeFormat = VideoDecodeFormat.P010
) : AutoCloseable {
private var formatContext: AVFormatContext? = null
private var codecContext: AVCodecContext? = null
private var swsContext: SwsContext? = null
private var packet: AVPacket? = null
private var frame: AVFrame? = null
private var frameOutput: AVFrame? = null
var videoStreamIndex: Int = -1
Index of the stream open decodes, or -1 before a successful open. open takes the first stream the container reports as video, and fails if there is none.
var width: Int
Frame width in pixels, read from the stream's codec parameters by open, and 0 until then.
var width: Int = 0
private set
var height: Int
Frame height in pixels, read from the stream's codec parameters by open, and 0 until then.
var height: Int = 0
private set
var duration: Double
Length of the video stream in seconds: its declared duration scaled by its time base, and 0.0 until open reads the header. A container that declares no duration stores a sentinel that is scaled like any other number, so check for a positive value before trusting it.
var duration: Double = 0.0
private set
var fps: Double
Average frame rate the stream declares, in frames per second, and 0.0 when the container gives none. VideoTexture falls back to a 0.033 s frame interval rather than dividing by a non-positive rate.
var fps: Double = 0.0
private set
private var isOpen = false
private var inputEnded = false
private var drainSent = false
private val tightBufferPool = VideoFrameBufferPool()
init
init {
// Note: av_register_all() is deprecated in FFmpeg 4.0+, registration is now implicit
// Initialize network protocols if needed
avformat_network_init()
}
fun open(): Boolean
Open the video file and initialize decoder.
body — 106 lines, from line 94
fun open(): Boolean {
if (isOpen) return true
try {
// Open video file
formatContext = avformat_alloc_context()
if (avformat_open_input(formatContext, videoPath, null, null) != 0) {
lprintln("error,video", "Could not open video file: $videoPath")
return false
}
// Retrieve stream information
if (avformat_find_stream_info(formatContext, null as PointerPointer<*>?) < 0) {
lprintln("error,video", "Could not find stream information")
return false
}
// Find the video stream
videoStreamIndex = -1
for (i in 0 until formatContext!!.nb_streams()) {
val stream = formatContext!!.streams(i)
if (stream.codecpar().codec_type() == AVMEDIA_TYPE_VIDEO) {
videoStreamIndex = i
break
}
}
if (videoStreamIndex == -1) {
lprintln("error,video", "Could not find video stream")
return false
}
val videoStream = formatContext!!.streams(videoStreamIndex)
val codecPar = videoStream.codecpar()
// Get video properties
width = codecPar.width()
height = codecPar.height()
// Calculate duration and FPS
val timeBase = videoStream.time_base()
duration = videoStream.duration() * av_q2d(timeBase)
val frameRate = videoStream.avg_frame_rate()
fps = av_q2d(frameRate)
// Find decoder
val codec = avcodec_find_decoder(codecPar.codec_id())
if (codec == null) {
lprintln("error,video", "Unsupported codec")
return false
}
// Allocate codec context
codecContext = avcodec_alloc_context3(codec)
if (avcodec_parameters_to_context(codecContext, codecPar) < 0) {
lprintln("error,video", "Could not copy codec parameters")
return false
}
// libavcodec's programmatic default is effectively single-threaded for this path, while
// the ffmpeg CLI enables frame/slice workers automatically. A 6912x1440 lossless RGB
// atlas is otherwise a half-second decode on an Apple desktop CPU.
codecContext!!.thread_count(min(8, Runtime.getRuntime().availableProcessors()))
codecContext!!.thread_type(AVCodecContext.FF_THREAD_FRAME or AVCodecContext.FF_THREAD_SLICE)
// Open codec
if (avcodec_open2(codecContext, codec, null as PointerPointer<*>?) < 0) {
lprintln("error,video", "Could not open codec")
return false
}
// Allocate frame structures
frame = av_frame_alloc()
frameOutput = av_frame_alloc()
packet = av_packet_alloc()
val avOutputFormat = when (outputFormat) {
VideoDecodeFormat.P010 -> AV_PIX_FMT_P010LE
VideoDecodeFormat.RGBA8 -> AV_PIX_FMT_RGBA
}
frameOutput!!.format(avOutputFormat)
frameOutput!!.width(width)
frameOutput!!.height(height)
av_frame_get_buffer(frameOutput, 32)
// swscale also handles native GBR/RGB codecs; RGBA8 deliberately avoids a YUV roundtrip.
swsContext = sws_getContext(
width, height, codecContext!!.pix_fmt(),
width, height, avOutputFormat,
SWS_BILINEAR, null, null, null as DoublePointer?
)
isOpen = true
inputEnded = false
drainSent = false
lprintln("info,video", "Video opened: ${width}x${height}, duration: ${duration}s, fps: $fps")
return true
} catch (e: Exception) {
lprintln("error,video", "Error opening video: ${e.message}")
lprintln("error,video", e.stackTraceToString())
cleanup()
return false
}
}
fun seek(timestampSeconds: Double): Boolean
Seek to a specific timestamp in seconds.
fun seek(timestampSeconds: Double): Boolean {
if (!isOpen) return false
try {
val videoStream = formatContext!!.streams(videoStreamIndex)
val timeBase = videoStream.time_base()
val timestamp = (timestampSeconds / av_q2d(timeBase)).toLong()
if (av_seek_frame(formatContext, videoStreamIndex, timestamp, AVSEEK_FLAG_BACKWARD) < 0) {
lprintln("error,video", "Error seeking to timestamp: $timestampSeconds")
return false
}
avcodec_flush_buffers(codecContext)
inputEnded = false
drainSent = false
return true
} catch (e: Exception) {
lprintln("error,video", "Error during seek: ${e.message}")
return false
}
}
fun decodeNextFrame(): VideoFrame?
Decode the next frame in outputFormat. Plane buffers are tightly packed even when FFmpeg's
working frame has aligned row padding, so Vulkan uploads never need to know the source stride.
body — 73 lines, from line 232
fun decodeNextFrame(): VideoFrame? {
if (!isOpen) return null
try {
if (!receiveNextFrame()) return null
// Convert to the requested upload format.
sws_scale(
swsContext,
frame!!.data(), frame!!.linesize(), 0, height,
frameOutput!!.data(), frameOutput!!.linesize()
)
val planes = when (outputFormat) {
VideoDecodeFormat.P010 -> {
val lumaStride = frameOutput!!.linesize(0)
val chromaStride = frameOutput!!.linesize(1)
if (lumaStride <= 0 || chromaStride <= 0) {
lprintln(
"error,video",
"Invalid P010 linesizes: luma=$lumaStride, chroma=$chromaStride"
)
return null
}
val rowBytes = width * 2
val luma = copyPlaneTight(frameOutput!!.data(0), lumaStride, rowBytes, height, tightBufferPool)
try {
DecodedPlanes(
luma,
copyPlaneTight(frameOutput!!.data(1), chromaStride, rowBytes, height / 2, tightBufferPool),
rowBytes,
rowBytes
)
} catch (t: Throwable) {
tightBufferPool.release(luma)
throw t
}
}
VideoDecodeFormat.RGBA8 -> {
val rgbaStride = frameOutput!!.linesize(0)
if (rgbaStride <= 0) {
lprintln("error,video", "Invalid RGBA linesize: $rgbaStride")
return null
}
val rowBytes = width * 4
DecodedPlanes(
copyPlaneTight(frameOutput!!.data(0), rgbaStride, rowBytes, height, tightBufferPool),
EMPTY_BUFFER,
rowBytes,
0
)
}
}
return VideoFrame(
width = width,
height = height,
format = outputFormat,
plane0 = planes.plane0,
plane1 = planes.plane1,
plane0Stride = planes.stride0,
plane1Stride = planes.stride1,
timestamp = frame!!.best_effort_timestamp() * av_q2d(formatContext!!.streams(videoStreamIndex).time_base()),
release = {
tightBufferPool.release(planes.plane0)
tightBufferPool.release(planes.plane1)
}
)
} catch (e: Exception) {
lprintln("error,video", "Error decoding frame: ${e.message}")
return null
}
}
Drain receive before sending another packet, including delayed/B frames after demux EOF.
private fun receiveNextFrame(): Boolean {
while (true) {
val received = avcodec_receive_frame(codecContext, frame)
if (received >= 0) return true
if (received == AVERROR_EOF) return false
check(received == AVERROR_EAGAIN()) { "avcodec_receive_frame failed: $received" }
if (inputEnded) {
if (drainSent) return false
val sent = avcodec_send_packet(codecContext, null as AVPacket?)
check(sent >= 0 || sent == AVERROR_EOF) { "video drain failed: $sent" }
drainSent = true
continue
}
while (true) {
if (av_read_frame(formatContext, packet) < 0) {
inputEnded = true
break
}
if (packet!!.stream_index() != videoStreamIndex) {
av_packet_unref(packet)
continue
}
val sent = avcodec_send_packet(codecContext, packet)
av_packet_unref(packet)
check(sent >= 0) { "avcodec_send_packet failed: $sent" }
break
}
}
}
override fun close()
Close the decoder and free resources.
body — 45 lines, from line 342
override fun close() {
if (!isOpen) return
cleanup()
}
private fun cleanup() {
// Free scaler context
swsContext?.let { sws_freeContext(it) }
swsContext = null
// Free frame structures (bytedeco handles the double-pointer semantics)
frame?.let { av_frame_free(it) }
frame = null
frameOutput?.let { av_frame_free(it) }
frameOutput = null
// Free packet structure
packet?.let { av_packet_free(it) }
packet = null
// Free codec context (bytedeco handles the double-pointer semantics)
codecContext?.let {
avcodec_free_context(it)
}
codecContext = null
// Close format context (automatically frees the context)
formatContext?.let {
avformat_close_input(it)
}
formatContext = null
isOpen = false
}
}
private val EMPTY_BUFFER: ByteBuffer = ByteBuffer.allocate(0).asReadOnlyBuffer()
private data class DecodedPlanes(
val plane0: ByteBuffer,
val plane1: ByteBuffer,
val stride0: Int,
val stride1: Int
)
Small cross-thread pool for the one or two frames that can be awaiting a Vulkan upload.
source — 53 lines, from line 389
private class VideoFrameBufferPool(private val maxRetained: Int = 4) {
private val available = ArrayDeque<ByteBuffer>()
@Synchronized
fun acquire(size: Int): ByteBuffer {
val iterator = available.iterator()
while (iterator.hasNext()) {
val candidate = iterator.next()
if (candidate.capacity() >= size) {
iterator.remove()
candidate.clear()
candidate.limit(size)
return candidate
}
}
return ByteBuffer.allocate(size)
}
@Synchronized
fun release(buffer: ByteBuffer) {
if (buffer.capacity() == 0 || buffer.isDirect || available.size >= maxRetained) return
buffer.clear()
available.addLast(buffer)
}
}
private fun copyPlaneTight(
pointer: org.bytedeco.javacpp.BytePointer,
sourceStride: Int,
rowBytes: Int,
rows: Int,
pool: VideoFrameBufferPool
): ByteBuffer {
require(sourceStride >= rowBytes) { "source stride $sourceStride is smaller than row width $rowBytes" }
val source = pointer.capacity(sourceStride.toLong() * rows).asByteBuffer()
// This is transient CPU upload storage, not memory passed directly to Vulkan. Keeping it on the
// managed heap is load-bearing for large atlases: sequential playback can replace several
// 6912x1440 RGBA frames per second, and equally short-lived allocateDirect buffers otherwise
// accumulate behind the Cleaner until the JVM's native footprint exhausts system memory.
val copy = pool.acquire(rowBytes * rows)
try {
repeat(rows) { row ->
val line = source.duplicate()
line.position(row * sourceStride)
line.limit(row * sourceStride + rowBytes)
copy.put(line)
}
return copy.flip()
} catch (t: Throwable) {
pool.release(copy)
throw t
}
}
enum class VideoDecodeFormat { P010, RGBA8 }
class VideoFrame
A decoded upload frame. P010 uses both planes; RGBA8 uses plane0 and leaves plane1 empty.
The owner must close the frame after upload or when superseding it so its large backing buffers
can be reused without waiting for garbage collection.
class VideoFrame(
/** Frame width in pixels, the [VideoDecoder.width] of the decoder that produced it. */
val width: Int,
/** Frame height in pixels, the [VideoDecoder.height] of the decoder that produced it. */
val height: Int,
/** Which planes carry data: P010 fills both, RGBA8 fills [plane0] and leaves [plane1] empty. */
val format: VideoDecodeFormat,
/**
* Luma samples for a P010 frame, or the whole image for an RGBA8 one. Rows are tightly packed
* however much padding FFmpeg's working frame carried, so an upload needs no source stride.
*/
val plane0: ByteBuffer,
/**
* Interleaved chroma samples at half the width and half the height for a P010 frame, and an
* empty buffer for an RGBA8 one.
*/
val plane1: ByteBuffer,
/**
* Bytes per row of [plane0]: twice the width for P010 luma, four times for RGBA8. Rows are
* packed, so this is the row width rather than a padded pitch.
*/
val plane0Stride: Int,
/**
* Bytes per row of [plane1], which for P010 is the same as [plane0Stride] because a chroma row
* holds half as many samples at twice the size. 0 for an RGBA8 frame.
*/
val plane1Stride: Int,
/**
* When this frame is shown, in seconds: FFmpeg's best-effort timestamp scaled by the stream's
* time base. [VideoFrameIndex.decoded] turns it into a frame number.
*/
val timestamp: Double,
private val release: () -> Unit = {}
) : AutoCloseable {
private val released = AtomicBoolean()
val lumaData: ByteBuffer get() = plane0
plane0 under its P010 name. For an RGBA8 frame this is the colour image, not luma.
val chromaData: ByteBuffer get() = plane1
plane1 under its P010 name, so it is the empty buffer for an RGBA8 frame.
val lumaStride: Int get() = plane0Stride
plane0Stride under its P010 name.
val chromaStride: Int get() = plane1Stride
plane1Stride under its P010 name, so it is 0 for an RGBA8 frame.
override fun close()
override fun close() {
if (released.compareAndSet(false, true)) release()
}
}