Skip to content

Commit e6bf67a

Browse files
authored
Merge pull request #150 from kdroidFilter/jvm-optimization
Refine video player frame processing and cleanup logic
2 parents e470aee + a86e4c5 commit e6bf67a

7 files changed

Lines changed: 672 additions & 179 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package io.github.kdroidfilter.composemediaplayer.linux
2+
3+
import java.nio.ByteBuffer
4+
5+
/**
6+
* Calculates a fast hash of the frame buffer to detect frame changes.
7+
* Samples approximately 200 pixels evenly distributed across the frame.
8+
*
9+
* @param buffer The source buffer containing RGBA pixel data
10+
* @param pixelCount Total number of pixels in the frame
11+
* @return A hash value representing the frame content
12+
*/
13+
internal fun calculateFrameHash(buffer: ByteBuffer, pixelCount: Int): Int {
14+
if (pixelCount <= 0) return 0
15+
16+
var hash = 1
17+
val step = if (pixelCount <= 200) 1 else pixelCount / 200
18+
for (i in 0 until pixelCount step step) {
19+
hash = 31 * hash + buffer.getInt(i * 4)
20+
}
21+
return hash
22+
}
23+
24+
/**
25+
* Copies RGBA frame data from source to destination buffer with minimal overhead.
26+
* Handles row padding when destination stride differs from source.
27+
*
28+
* This function performs a single memory copy operation when strides match,
29+
* achieving zero-copy performance (beyond the necessary single copy from
30+
* GStreamer buffer to Skia bitmap).
31+
*
32+
* @param src Source buffer containing RGBA pixel data from GStreamer
33+
* @param dst Destination buffer (Skia bitmap pixels via peekPixels)
34+
* @param width Frame width in pixels
35+
* @param height Frame height in pixels
36+
* @param dstRowBytes Destination row stride (may include padding)
37+
*/
38+
internal fun copyRgbaFrame(
39+
src: ByteBuffer,
40+
dst: ByteBuffer,
41+
width: Int,
42+
height: Int,
43+
dstRowBytes: Int,
44+
) {
45+
require(width > 0) { "width must be > 0 (was $width)" }
46+
require(height > 0) { "height must be > 0 (was $height)" }
47+
val srcRowBytes = width * 4
48+
require(dstRowBytes >= srcRowBytes) {
49+
"dstRowBytes ($dstRowBytes) must be >= srcRowBytes ($srcRowBytes)"
50+
}
51+
52+
val requiredSrcBytes = srcRowBytes.toLong() * height.toLong()
53+
val requiredDstBytes = dstRowBytes.toLong() * height.toLong()
54+
require(src.capacity().toLong() >= requiredSrcBytes) {
55+
"src buffer too small: ${src.capacity()} < $requiredSrcBytes"
56+
}
57+
require(dst.capacity().toLong() >= requiredDstBytes) {
58+
"dst buffer too small: ${dst.capacity()} < $requiredDstBytes"
59+
}
60+
61+
val srcBuf = src.duplicate()
62+
val dstBuf = dst.duplicate()
63+
srcBuf.rewind()
64+
dstBuf.rewind()
65+
66+
// Fast path: when strides match, do a single bulk copy
67+
if (dstRowBytes == srcRowBytes) {
68+
srcBuf.limit(requiredSrcBytes.toInt())
69+
dstBuf.limit(requiredSrcBytes.toInt())
70+
dstBuf.put(srcBuf)
71+
return
72+
}
73+
74+
// Slow path: copy row by row when there's padding
75+
val srcCapacity = srcBuf.capacity()
76+
val dstCapacity = dstBuf.capacity()
77+
for (row in 0 until height) {
78+
val srcPos = row * srcRowBytes
79+
srcBuf.limit(srcCapacity)
80+
srcBuf.position(srcPos)
81+
srcBuf.limit(srcPos + srcRowBytes)
82+
83+
val dstPos = row * dstRowBytes
84+
dstBuf.limit(dstCapacity)
85+
dstBuf.position(dstPos)
86+
dstBuf.limit(dstPos + srcRowBytes)
87+
88+
dstBuf.put(srcBuf)
89+
}
90+
}

mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt

Lines changed: 75 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@ import org.freedesktop.gstreamer.Format
2525
import org.freedesktop.gstreamer.event.SeekFlags
2626
import org.freedesktop.gstreamer.event.SeekType
2727
import org.freedesktop.gstreamer.message.MessageType
28+
import com.sun.jna.Pointer
2829
import org.jetbrains.skia.Bitmap
2930
import org.jetbrains.skia.ColorAlphaType
3031
import org.jetbrains.skia.ColorType
3132
import org.jetbrains.skia.ImageInfo
3233
import java.awt.EventQueue
3334
import java.io.File
3435
import java.net.URI
36+
import java.nio.ByteBuffer
3537
import java.util.EnumSet
3638
import javax.swing.Timer
3739
import kotlin.math.abs
@@ -71,6 +73,12 @@ class LinuxVideoPlayerState : PlatformVideoPlayerState {
7173
private var frameWidth = 0
7274
private var frameHeight = 0
7375

76+
// Double-buffering for zero-copy frame rendering
77+
private var skiaBitmapA: Bitmap? = null
78+
private var skiaBitmapB: Bitmap? = null
79+
private var nextSkiaBitmapA: Boolean = true
80+
private var lastFrameHash: Int = Int.MIN_VALUE
81+
7482
private var bufferingPercent by mutableStateOf(100)
7583
private var isUserPaused by mutableStateOf(false)
7684
private var hasReceivedFirstFrame by mutableStateOf(false)
@@ -776,6 +784,7 @@ class LinuxVideoPlayerState : PlatformVideoPlayerState {
776784
_isSeeking = false
777785
hasReceivedFirstFrame = false
778786
_currentFrame = null
787+
lastFrameHash = Int.MIN_VALUE
779788
}
780789

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

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

844+
if (width <= 0 || height <= 0) return
845+
846+
// Handle dimension changes
829847
if (width != frameWidth || height != frameHeight) {
830848
frameWidth = width
831849
frameHeight = height
850+
851+
// Reallocate bitmaps for new dimensions
852+
skiaBitmapA?.close()
853+
skiaBitmapB?.close()
854+
855+
val imageInfo = ImageInfo(width, height, ColorType.RGBA_8888, ColorAlphaType.UNPREMUL)
856+
skiaBitmapA = Bitmap().apply { allocPixels(imageInfo) }
857+
skiaBitmapB = Bitmap().apply { allocPixels(imageInfo) }
858+
nextSkiaBitmapA = true
859+
lastFrameHash = Int.MIN_VALUE
860+
832861
updateAspectRatio()
833862
}
834863

835864
val buffer = sample.buffer ?: return
836-
val byteBuffer = buffer.map(false) ?: return
837-
byteBuffer.rewind()
838-
839-
// Prepare a Skia Bitmap
840-
val imageInfo = ImageInfo(
841-
width,
842-
height,
843-
ColorType.RGBA_8888,
844-
ColorAlphaType.UNPREMUL
845-
)
865+
val srcBuffer = buffer.map(false) ?: return
866+
srcBuffer.rewind()
867+
868+
val pixelCount = width * height
846869

847-
val bitmap = Bitmap()
848-
bitmap.allocPixels(imageInfo)
870+
// Calculate frame hash to detect identical frames
871+
val newHash = calculateFrameHash(srcBuffer, pixelCount)
872+
if (newHash == lastFrameHash) {
873+
buffer.unmap()
874+
return
875+
}
876+
lastFrameHash = newHash
877+
878+
// Select the target bitmap (double-buffering)
879+
val targetBitmap = if (nextSkiaBitmapA) skiaBitmapA!! else skiaBitmapB!!
880+
nextSkiaBitmapA = !nextSkiaBitmapA
849881

850-
// Get the byte array from the buffer directly
851-
val totalBytes = width * height * 4
852-
val byteArray = ByteArray(totalBytes)
882+
// Get direct access to bitmap pixels via peekPixels (zero-copy access)
883+
val pixmap = targetBitmap.peekPixels() ?: run {
884+
buffer.unmap()
885+
return
886+
}
853887

854-
// Bulk copy the bytes from the buffer to the array
855-
// This is much more efficient than copying pixel by pixel
856-
byteBuffer.get(byteArray, 0, totalBytes)
888+
val pixelsAddr = pixmap.addr
889+
if (pixelsAddr == 0L) {
890+
buffer.unmap()
891+
return
892+
}
857893

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

861-
// Convert the Skia Bitmap into a Compose ImageBitmap
862-
val imageBitmap = bitmap.asComposeImageBitmap()
899+
srcBuffer.rewind()
900+
copyRgbaFrame(srcBuffer, dstBuffer, width, height, dstRowBytes)
901+
902+
// Convert to Compose ImageBitmap
903+
val imageBitmap = targetBitmap.asComposeImageBitmap()
863904

864905
// Update on the AWT thread
865906
EventQueue.invokeLater {
@@ -884,6 +925,14 @@ class LinuxVideoPlayerState : PlatformVideoPlayerState {
884925
playbin.stop()
885926
playbin.dispose()
886927
videoSink.dispose()
928+
929+
// Clean up double-buffering bitmaps
930+
skiaBitmapA?.close()
931+
skiaBitmapB?.close()
932+
skiaBitmapA = null
933+
skiaBitmapB = null
934+
lastFrameHash = Int.MIN_VALUE
935+
887936
// Don't call Gst.deinit() here as it would affect all instances
888937
// Each instance should only clean up its own resources
889938
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package io.github.kdroidfilter.composemediaplayer.mac
2+
3+
import java.nio.ByteBuffer
4+
5+
internal fun calculateFrameHash(buffer: ByteBuffer, pixelCount: Int): Int {
6+
if (pixelCount <= 0) return 0
7+
8+
var hash = 1
9+
val step = if (pixelCount <= 200) 1 else pixelCount / 200
10+
for (i in 0 until pixelCount step step) {
11+
hash = 31 * hash + buffer.getInt(i * 4)
12+
}
13+
return hash
14+
}
15+
16+
internal fun copyBgraFrame(
17+
src: ByteBuffer,
18+
dst: ByteBuffer,
19+
width: Int,
20+
height: Int,
21+
dstRowBytes: Int,
22+
) {
23+
require(width > 0) { "width must be > 0 (was $width)" }
24+
require(height > 0) { "height must be > 0 (was $height)" }
25+
val srcRowBytes = width * 4
26+
require(dstRowBytes >= srcRowBytes) {
27+
"dstRowBytes ($dstRowBytes) must be >= srcRowBytes ($srcRowBytes)"
28+
}
29+
30+
val requiredSrcBytes = srcRowBytes.toLong() * height.toLong()
31+
val requiredDstBytes = dstRowBytes.toLong() * height.toLong()
32+
require(src.capacity().toLong() >= requiredSrcBytes) {
33+
"src buffer too small: ${src.capacity()} < $requiredSrcBytes"
34+
}
35+
require(dst.capacity().toLong() >= requiredDstBytes) {
36+
"dst buffer too small: ${dst.capacity()} < $requiredDstBytes"
37+
}
38+
39+
val srcBuf = src.duplicate()
40+
val dstBuf = dst.duplicate()
41+
srcBuf.rewind()
42+
dstBuf.rewind()
43+
44+
if (dstRowBytes == srcRowBytes) {
45+
srcBuf.limit(requiredSrcBytes.toInt())
46+
dstBuf.limit(requiredSrcBytes.toInt())
47+
dstBuf.put(srcBuf)
48+
return
49+
}
50+
51+
val srcCapacity = srcBuf.capacity()
52+
val dstCapacity = dstBuf.capacity()
53+
for (row in 0 until height) {
54+
val srcPos = row * srcRowBytes
55+
srcBuf.limit(srcCapacity)
56+
srcBuf.position(srcPos)
57+
srcBuf.limit(srcPos + srcRowBytes)
58+
59+
val dstPos = row * dstRowBytes
60+
dstBuf.limit(dstCapacity)
61+
dstBuf.position(dstPos)
62+
dstBuf.limit(dstPos + srcRowBytes)
63+
64+
dstBuf.put(srcBuf)
65+
}
66+
}

0 commit comments

Comments
 (0)