From 6929d0d2d34fb9eb012e48f0bf8dd01a2e3ff4d9 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Tue, 15 Sep 2026 23:08:32 -0400 Subject: [PATCH] Capture full YUV planes and add YUV420->ARGB converter - VideoDecoder.Frame and PlaybackEngine.VideoFrame now carry U and V chroma planes plus their strides, in addition to luma. - YuvConverter converts YUVJ420P frames to packed 0xAARRGGBB pixels for the renderer, with unit tests. --- .../dev/quoije/umd/media/VideoDecoder.java | 31 +++++++--- .../dev/quoije/umd/media/YuvConverter.java | 56 +++++++++++++++++++ .../dev/quoije/umd/player/PlaybackEngine.java | 11 +++- .../quoije/umd/media/YuvConverterTest.java | 42 ++++++++++++++ 4 files changed, 130 insertions(+), 10 deletions(-) create mode 100644 core/src/main/java/dev/quoije/umd/media/YuvConverter.java create mode 100644 core/src/test/java/dev/quoije/umd/media/YuvConverterTest.java diff --git a/core/src/main/java/dev/quoije/umd/media/VideoDecoder.java b/core/src/main/java/dev/quoije/umd/media/VideoDecoder.java index 5f013d3..cb946af 100644 --- a/core/src/main/java/dev/quoije/umd/media/VideoDecoder.java +++ b/core/src/main/java/dev/quoije/umd/media/VideoDecoder.java @@ -95,16 +95,33 @@ public final class VideoDecoder implements AutoCloseable { int width = frame.width(); int height = frame.height(); - BytePointer y = frame.data(0); - int stride = frame.linesize(0); - byte[] yPlane = new byte[height * stride]; - y.position(0).get(yPlane); + int chromaWidth = (width + 1) / 2; + int chromaHeight = (height + 1) / 2; - return new Frame(width, height, frame.format(), yPlane, stride); + BytePointer y = frame.data(0); + BytePointer u = frame.data(1); + BytePointer v = frame.data(2); + + int yStride = frame.linesize(0); + int uStride = frame.linesize(1); + int vStride = frame.linesize(2); + + byte[] yPlane = new byte[height * yStride]; + byte[] uPlane = new byte[chromaHeight * uStride]; + byte[] vPlane = new byte[chromaHeight * vStride]; + + y.position(0).get(yPlane); + u.position(0).get(uPlane); + v.position(0).get(vPlane); + + return new Frame(width, height, frame.format(), yPlane, yStride, uPlane, uStride, vPlane, vStride); } - /** A decoded video frame. */ - public record Frame(int width, int height, int pixelFormat, byte[] yPlane, int yStride) { + /** A decoded video frame (planar YUV 4:2:0). */ + public record Frame(int width, int height, int pixelFormat, + byte[] yPlane, int yStride, + byte[] uPlane, int uStride, + byte[] vPlane, int vStride) { } /** Returns the next decoded frame, or {@code null} if none is buffered. */ diff --git a/core/src/main/java/dev/quoije/umd/media/YuvConverter.java b/core/src/main/java/dev/quoije/umd/media/YuvConverter.java new file mode 100644 index 0000000..a93544e --- /dev/null +++ b/core/src/main/java/dev/quoije/umd/media/YuvConverter.java @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.quoije.umd.media; + +/** + * Converts decoded video frames (planar YUV 4:2:0, full range as used by + * JPEG/YUVJ420P) into packed 0xAARRGGBB pixels. + */ +public final class YuvConverter { + + private YuvConverter() { + } + + /** + * Converts a YUVJ420P frame to ARGB. The returned array has + * {@code width * height} packed pixels. + */ + public static int[] yuvj420ToArgb(int width, int height, + byte[] yPlane, int yStride, + byte[] uPlane, int uStride, + byte[] vPlane, int vStride) { + int[] argb = new int[width * height]; + + for (int row = 0; row < height; row++) { + int yRow = row * yStride; + int uvRow = (row >> 1) * uStride; + int outRow = row * width; + + for (int col = 0; col < width; col++) { + int y = yPlane[yRow + col] & 0xFF; + int u = uPlane[uvRow + (col >> 1)] & 0xFF; + int v = vPlane[uvRow + (col >> 1)] & 0xFF; + + int du = u - 128; + int dv = v - 128; + + int r = y + ((359 * dv) >> 8); + int g = y - ((88 * du) >> 8) - ((183 * dv) >> 8); + int b = y + ((454 * du) >> 8); + + argb[outRow + col] = 0xFF000000 + | (clamp(r) << 16) + | (clamp(g) << 8) + | clamp(b); + } + } + + return argb; + } + + private static int clamp(int value) { + if (value < 0) { + return 0; + } + return Math.min(value, 255); + } +} diff --git a/core/src/main/java/dev/quoije/umd/player/PlaybackEngine.java b/core/src/main/java/dev/quoije/umd/player/PlaybackEngine.java index 35631ff..35618c2 100644 --- a/core/src/main/java/dev/quoije/umd/player/PlaybackEngine.java +++ b/core/src/main/java/dev/quoije/umd/player/PlaybackEngine.java @@ -31,7 +31,10 @@ public final class PlaybackEngine implements AutoCloseable { public static final long AUDIO_FRAME_TICKS = (long) AUDIO_FRAME_SAMPLES * MPEG_TIMESTAMP / AUDIO_SAMPLE_RATE; /** A decoded video frame tagged with its presentation timestamp. */ - public record VideoFrame(long pts, int width, int height, int pixelFormat, byte[] yPlane, int yStride) { + public record VideoFrame(long pts, int width, int height, int pixelFormat, + byte[] yPlane, int yStride, + byte[] uPlane, int uStride, + byte[] vPlane, int vStride) { } /** A chunk of interleaved S16 PCM tagged with its presentation timestamp. */ @@ -136,7 +139,8 @@ public final class PlaybackEngine implements AutoCloseable { while ((frame = videoDecoder.receive()) != null) { videoQueue.add(new VideoFrame( assignVideoPts(), frame.width(), frame.height(), - frame.pixelFormat(), frame.yPlane(), frame.yStride())); + frame.pixelFormat(), frame.yPlane(), frame.yStride(), + frame.uPlane(), frame.uStride(), frame.vPlane(), frame.vStride())); } } } @@ -202,7 +206,8 @@ public final class PlaybackEngine implements AutoCloseable { while ((frame = videoDecoder.receive()) != null) { videoQueue.add(new VideoFrame( assignVideoPts(), frame.width(), frame.height(), - frame.pixelFormat(), frame.yPlane(), frame.yStride())); + frame.pixelFormat(), frame.yPlane(), frame.yStride(), + frame.uPlane(), frame.uStride(), frame.vPlane(), frame.vStride())); } if (audioDecoder != null) { diff --git a/core/src/test/java/dev/quoije/umd/media/YuvConverterTest.java b/core/src/test/java/dev/quoije/umd/media/YuvConverterTest.java new file mode 100644 index 0000000..a2f6ae6 --- /dev/null +++ b/core/src/test/java/dev/quoije/umd/media/YuvConverterTest.java @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.quoije.umd.media; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class YuvConverterTest { + + @Test + void convertsKnownColours() { + // 2x2 frame; Y=255 everywhere (white), U=V=128 (no chroma) -> white. + byte[] y = new byte[4]; + byte[] u = new byte[]{(byte) 128}; + byte[] v = new byte[]{(byte) 128}; + java.util.Arrays.fill(y, (byte) 255); + + int[] argb = YuvConverter.yuvj420ToArgb(2, 2, y, 2, u, 1, v, 1); + assertThat(argb).containsExactly(0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF); + + // Y=0, U=V=128 -> black. + java.util.Arrays.fill(y, (byte) 0); + argb = YuvConverter.yuvj420ToArgb(2, 2, y, 2, u, 1, v, 1); + assertThat(argb).containsExactly(0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000); + } + + @Test + void respectsChroma() { + // Y=0, U=128, V=255 -> strong red (V - 128 = 127 => r = 0 + 1.402*127 ~ 178). + byte[] y = new byte[]{0, 0, 0, 0}; + byte[] u = new byte[]{-128}; // 128 + byte[] v = new byte[]{-1}; // 255 + + int[] argb = YuvConverter.yuvj420ToArgb(2, 2, y, 2, u, 1, v, 1); + int r = (argb[0] >> 16) & 0xFF; + int g = (argb[0] >> 8) & 0xFF; + int b = argb[0] & 0xFF; + assertThat(r).isGreaterThan(150); + assertThat(b).isEqualTo(0); + assertThat(g).isLessThanOrEqualTo(0); + } +}