Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package io.github.kdroidfilter.composemediaplayer.linux

import java.nio.ByteBuffer

/**
* Calculates a fast hash of the frame buffer to detect frame changes.
* Samples approximately 200 pixels evenly distributed across the frame.
*
* @param buffer The source buffer containing RGBA pixel data
* @param pixelCount Total number of pixels in the frame
* @return A hash value representing the frame content
*/
internal fun calculateFrameHash(buffer: ByteBuffer, pixelCount: Int): Int {
if (pixelCount <= 0) return 0

var hash = 1
val step = if (pixelCount <= 200) 1 else pixelCount / 200
for (i in 0 until pixelCount step step) {
hash = 31 * hash + buffer.getInt(i * 4)
}
return hash
}

/**
* Copies RGBA frame data from source to destination buffer with minimal overhead.
* Handles row padding when destination stride differs from source.
*
* This function performs a single memory copy operation when strides match,
* achieving zero-copy performance (beyond the necessary single copy from
* GStreamer buffer to Skia bitmap).
*
* @param src Source buffer containing RGBA pixel data from GStreamer
* @param dst Destination buffer (Skia bitmap pixels via peekPixels)
* @param width Frame width in pixels
* @param height Frame height in pixels
* @param dstRowBytes Destination row stride (may include padding)
*/
internal fun copyRgbaFrame(
src: ByteBuffer,
dst: ByteBuffer,
width: Int,
height: Int,
dstRowBytes: Int,
) {
require(width > 0) { "width must be > 0 (was $width)" }
require(height > 0) { "height must be > 0 (was $height)" }
val srcRowBytes = width * 4
require(dstRowBytes >= srcRowBytes) {
"dstRowBytes ($dstRowBytes) must be >= srcRowBytes ($srcRowBytes)"
}

val requiredSrcBytes = srcRowBytes.toLong() * height.toLong()
val requiredDstBytes = dstRowBytes.toLong() * height.toLong()
require(src.capacity().toLong() >= requiredSrcBytes) {
"src buffer too small: ${src.capacity()} < $requiredSrcBytes"
}
require(dst.capacity().toLong() >= requiredDstBytes) {
"dst buffer too small: ${dst.capacity()} < $requiredDstBytes"
}

val srcBuf = src.duplicate()
val dstBuf = dst.duplicate()
srcBuf.rewind()
dstBuf.rewind()

// Fast path: when strides match, do a single bulk copy
if (dstRowBytes == srcRowBytes) {
srcBuf.limit(requiredSrcBytes.toInt())
dstBuf.limit(requiredSrcBytes.toInt())
dstBuf.put(srcBuf)
return
}

// Slow path: copy row by row when there's padding
val srcCapacity = srcBuf.capacity()
val dstCapacity = dstBuf.capacity()
for (row in 0 until height) {
val srcPos = row * srcRowBytes
srcBuf.limit(srcCapacity)
srcBuf.position(srcPos)
srcBuf.limit(srcPos + srcRowBytes)

val dstPos = row * dstRowBytes
dstBuf.limit(dstCapacity)
dstBuf.position(dstPos)
dstBuf.limit(dstPos + srcRowBytes)

dstBuf.put(srcBuf)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ import org.freedesktop.gstreamer.Format
import org.freedesktop.gstreamer.event.SeekFlags
import org.freedesktop.gstreamer.event.SeekType
import org.freedesktop.gstreamer.message.MessageType
import com.sun.jna.Pointer
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ColorType
import org.jetbrains.skia.ImageInfo
import java.awt.EventQueue
import java.io.File
import java.net.URI
import java.nio.ByteBuffer
import java.util.EnumSet
import javax.swing.Timer
import kotlin.math.abs
Expand Down Expand Up @@ -71,6 +73,12 @@ class LinuxVideoPlayerState : PlatformVideoPlayerState {
private var frameWidth = 0
private var frameHeight = 0

// Double-buffering for zero-copy frame rendering
private var skiaBitmapA: Bitmap? = null
private var skiaBitmapB: Bitmap? = null
private var nextSkiaBitmapA: Boolean = true
private var lastFrameHash: Int = Int.MIN_VALUE

private var bufferingPercent by mutableStateOf(100)
private var isUserPaused by mutableStateOf(false)
private var hasReceivedFirstFrame by mutableStateOf(false)
Expand Down Expand Up @@ -776,6 +784,7 @@ class LinuxVideoPlayerState : PlatformVideoPlayerState {
_isSeeking = false
hasReceivedFirstFrame = false
_currentFrame = null
lastFrameHash = Int.MIN_VALUE
}

override fun seekTo(value: Float) {
Expand Down Expand Up @@ -813,10 +822,16 @@ class LinuxVideoPlayerState : PlatformVideoPlayerState {

// ---- Processing of a video sample ----
/**
* Directly reads in RGBA and copies to a Skia Bitmap in the RGBA_8888 format
* (non-premultiplied). This avoids redundant conversions to maintain accurate colors and performance.
*
* Optimized for better performance, especially in fullscreen mode.
* Zero-copy optimized frame processing using double-buffering and direct memory access.
*
* Optimizations applied:
* 1. Double-buffering: Reuses two Bitmap objects, alternating between them to avoid
* allocating new bitmaps every frame while the UI draws from the previous one.
* 2. Frame hashing: Skips processing if the frame content hasn't changed (identical frames).
* 3. peekPixels(): Direct access to Skia bitmap memory, avoiding intermediate ByteArray allocation.
* 4. Single memory copy: GStreamer buffer → Skia bitmap pixels (true zero-copy beyond this necessary transfer).
*
* Memory flow: GStreamer native buffer → Skia bitmap pixels (1 copy via bulk ByteBuffer.put)
*/
private fun processSample(sample: Sample) {
try {
Expand All @@ -826,40 +841,66 @@ class LinuxVideoPlayerState : PlatformVideoPlayerState {
val width = structure.getInteger("width")
val height = structure.getInteger("height")

if (width <= 0 || height <= 0) return

// Handle dimension changes
if (width != frameWidth || height != frameHeight) {
frameWidth = width
frameHeight = height

// Reallocate bitmaps for new dimensions
skiaBitmapA?.close()
skiaBitmapB?.close()

val imageInfo = ImageInfo(width, height, ColorType.RGBA_8888, ColorAlphaType.UNPREMUL)
skiaBitmapA = Bitmap().apply { allocPixels(imageInfo) }
skiaBitmapB = Bitmap().apply { allocPixels(imageInfo) }
nextSkiaBitmapA = true
lastFrameHash = Int.MIN_VALUE

updateAspectRatio()
}

val buffer = sample.buffer ?: return
val byteBuffer = buffer.map(false) ?: return
byteBuffer.rewind()

// Prepare a Skia Bitmap
val imageInfo = ImageInfo(
width,
height,
ColorType.RGBA_8888,
ColorAlphaType.UNPREMUL
)
val srcBuffer = buffer.map(false) ?: return
srcBuffer.rewind()

val pixelCount = width * height

val bitmap = Bitmap()
bitmap.allocPixels(imageInfo)
// Calculate frame hash to detect identical frames
val newHash = calculateFrameHash(srcBuffer, pixelCount)
if (newHash == lastFrameHash) {
buffer.unmap()
return
}
lastFrameHash = newHash

// Select the target bitmap (double-buffering)
val targetBitmap = if (nextSkiaBitmapA) skiaBitmapA!! else skiaBitmapB!!
nextSkiaBitmapA = !nextSkiaBitmapA

// Get the byte array from the buffer directly
val totalBytes = width * height * 4
val byteArray = ByteArray(totalBytes)
// Get direct access to bitmap pixels via peekPixels (zero-copy access)
val pixmap = targetBitmap.peekPixels() ?: run {
buffer.unmap()
return
}

// Bulk copy the bytes from the buffer to the array
// This is much more efficient than copying pixel by pixel
byteBuffer.get(byteArray, 0, totalBytes)
val pixelsAddr = pixmap.addr
if (pixelsAddr == 0L) {
buffer.unmap()
return
}

// Install these pixels into the Bitmap
bitmap.installPixels(imageInfo, byteArray, width * 4)
// Single memory copy: GStreamer buffer → Skia bitmap
val dstRowBytes = pixmap.rowBytes.toInt()
val dstSizeBytes = dstRowBytes.toLong() * height.toLong()
val dstBuffer = Pointer(pixelsAddr).getByteBuffer(0, dstSizeBytes)

// Convert the Skia Bitmap into a Compose ImageBitmap
val imageBitmap = bitmap.asComposeImageBitmap()
srcBuffer.rewind()
copyRgbaFrame(srcBuffer, dstBuffer, width, height, dstRowBytes)

// Convert to Compose ImageBitmap
val imageBitmap = targetBitmap.asComposeImageBitmap()

// Update on the AWT thread
EventQueue.invokeLater {
Expand All @@ -884,6 +925,14 @@ class LinuxVideoPlayerState : PlatformVideoPlayerState {
playbin.stop()
playbin.dispose()
videoSink.dispose()

// Clean up double-buffering bitmaps
skiaBitmapA?.close()
skiaBitmapB?.close()
skiaBitmapA = null
skiaBitmapB = null
lastFrameHash = Int.MIN_VALUE

// Don't call Gst.deinit() here as it would affect all instances
// Each instance should only clean up its own resources
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package io.github.kdroidfilter.composemediaplayer.mac

import java.nio.ByteBuffer

internal fun calculateFrameHash(buffer: ByteBuffer, pixelCount: Int): Int {
if (pixelCount <= 0) return 0

var hash = 1
val step = if (pixelCount <= 200) 1 else pixelCount / 200
for (i in 0 until pixelCount step step) {
hash = 31 * hash + buffer.getInt(i * 4)
}
return hash
}

internal fun copyBgraFrame(
src: ByteBuffer,
dst: ByteBuffer,
width: Int,
height: Int,
dstRowBytes: Int,
) {
require(width > 0) { "width must be > 0 (was $width)" }
require(height > 0) { "height must be > 0 (was $height)" }
val srcRowBytes = width * 4
require(dstRowBytes >= srcRowBytes) {
"dstRowBytes ($dstRowBytes) must be >= srcRowBytes ($srcRowBytes)"
}

val requiredSrcBytes = srcRowBytes.toLong() * height.toLong()
val requiredDstBytes = dstRowBytes.toLong() * height.toLong()
require(src.capacity().toLong() >= requiredSrcBytes) {
"src buffer too small: ${src.capacity()} < $requiredSrcBytes"
}
require(dst.capacity().toLong() >= requiredDstBytes) {
"dst buffer too small: ${dst.capacity()} < $requiredDstBytes"
}

val srcBuf = src.duplicate()
val dstBuf = dst.duplicate()
srcBuf.rewind()
dstBuf.rewind()

if (dstRowBytes == srcRowBytes) {
srcBuf.limit(requiredSrcBytes.toInt())
dstBuf.limit(requiredSrcBytes.toInt())
dstBuf.put(srcBuf)
return
}

val srcCapacity = srcBuf.capacity()
val dstCapacity = dstBuf.capacity()
for (row in 0 until height) {
val srcPos = row * srcRowBytes
srcBuf.limit(srcCapacity)
srcBuf.position(srcPos)
srcBuf.limit(srcPos + srcRowBytes)

val dstPos = row * dstRowBytes
dstBuf.limit(dstCapacity)
dstBuf.position(dstPos)
dstBuf.limit(dstPos + srcRowBytes)

dstBuf.put(srcBuf)
}
}
Loading