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.
This commit is contained in:
2026-09-15 23:08:32 -04:00
parent b1896cd71b
commit 6929d0d2d3
4 changed files with 130 additions and 10 deletions
@@ -95,16 +95,33 @@ public final class VideoDecoder implements AutoCloseable {
int width = frame.width(); int width = frame.width();
int height = frame.height(); int height = frame.height();
BytePointer y = frame.data(0); int chromaWidth = (width + 1) / 2;
int stride = frame.linesize(0); int chromaHeight = (height + 1) / 2;
byte[] yPlane = new byte[height * stride];
y.position(0).get(yPlane);
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. */ /** A decoded video frame (planar YUV 4:2:0). */
public record Frame(int width, int height, int pixelFormat, byte[] yPlane, int yStride) { 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. */ /** Returns the next decoded frame, or {@code null} if none is buffered. */
@@ -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);
}
}
@@ -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; 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. */ /** 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. */ /** 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) { while ((frame = videoDecoder.receive()) != null) {
videoQueue.add(new VideoFrame( videoQueue.add(new VideoFrame(
assignVideoPts(), frame.width(), frame.height(), 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) { while ((frame = videoDecoder.receive()) != null) {
videoQueue.add(new VideoFrame( videoQueue.add(new VideoFrame(
assignVideoPts(), frame.width(), frame.height(), 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) { if (audioDecoder != null) {
@@ -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);
}
}