From 56c9d7d9eba04058e9910e05c8cccb7c7b06ad4b Mon Sep 17 00:00:00 2001 From: Alex Andres Date: Thu, 22 Oct 2026 18:11:21 +0200 Subject: [PATCH] fix: bound AudioResampler buffers and keep cropped I420 buffers alive AudioResampler passed the output array's capacity instead of the requested samples per channel and ignored the channel count, so stereo wrote past the array. Both arrays are now checked against samplesPerChannel * channels. Cropping a NativeI420Buffer without scaling returned a wrapper with no native handle over the source's memory. The crop is now a native buffer that holds a reference to its source. --- .../main/cpp/include/JNI_NativeI420Buffer.h | 8 ++ .../src/main/cpp/src/JNI_AudioResampler.cpp | 10 ++ .../src/main/cpp/src/JNI_NativeI420Buffer.cpp | 29 ++++ .../src/main/cpp/src/api/VideoFrame.cpp | 25 +++- .../webrtc/media/audio/AudioResampler.java | 70 ++++++++-- .../webrtc/media/video/NativeI420Buffer.java | 61 ++------- .../media/audio/AudioResamplerTest.java | 92 ++++++++++++- .../media/video/NativeI420BufferTest.java | 128 ++++++++++++++++++ 8 files changed, 362 insertions(+), 61 deletions(-) create mode 100644 webrtc/src/test/java/dev/onvoid/webrtc/media/video/NativeI420BufferTest.java diff --git a/webrtc-jni/src/main/cpp/include/JNI_NativeI420Buffer.h b/webrtc-jni/src/main/cpp/include/JNI_NativeI420Buffer.h index 39984841f..89f5aed07 100644 --- a/webrtc-jni/src/main/cpp/include/JNI_NativeI420Buffer.h +++ b/webrtc-jni/src/main/cpp/include/JNI_NativeI420Buffer.h @@ -23,6 +23,14 @@ extern "C" { JNIEXPORT jobject JNICALL Java_dev_onvoid_webrtc_media_video_NativeI420Buffer_copy (JNIEnv*, jclass, jint, jint, jobject, jint, jobject, jint, jobject, jint); + /* + * Class: dev_onvoid_webrtc_media_video_NativeI420Buffer + * Method: crop + * Signature: (IIII)Ldev/onvoid/webrtc/media/video/NativeI420Buffer; + */ + JNIEXPORT jobject JNICALL Java_dev_onvoid_webrtc_media_video_NativeI420Buffer_crop + (JNIEnv *, jobject, jint, jint, jint, jint); + /* * Class: dev_onvoid_webrtc_media_video_NativeI420Buffer * Method: cropAndScale diff --git a/webrtc-jni/src/main/cpp/src/JNI_AudioResampler.cpp b/webrtc-jni/src/main/cpp/src/JNI_AudioResampler.cpp index 8322a6345..45c777819 100644 --- a/webrtc-jni/src/main/cpp/src/JNI_AudioResampler.cpp +++ b/webrtc-jni/src/main/cpp/src/JNI_AudioResampler.cpp @@ -68,6 +68,16 @@ JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_media_audio_AudioResampler_resampl webrtc::PushResampler * resampler = GetHandle>(env, caller); CHECK_HANDLEV(resampler, -1); + // Java checks these too, but the views below must never reach past an array. + const jlong srcBytes = static_cast(srcSamplesPerChannel) * channels * sizeof(int16_t); + const jlong dstBytes = static_cast(dstSamplesPerChannel) * channels * sizeof(int16_t); + + if (srcSamplesPerChannel <= 0 || dstSamplesPerChannel <= 0 || channels <= 0 || + env->GetArrayLength(samplesIn) < srcBytes || env->GetArrayLength(samplesOut) < dstBytes) { + ThrowJavaException(env, "java/lang/IllegalArgumentException", "Sample arrays too short for the requested samples"); + return -1; + } + auto releaseSrc = [env, samplesIn](jbyte * bytes) { env->ReleaseByteArrayElements(samplesIn, bytes, JNI_ABORT); }; diff --git a/webrtc-jni/src/main/cpp/src/JNI_NativeI420Buffer.cpp b/webrtc-jni/src/main/cpp/src/JNI_NativeI420Buffer.cpp index 3bcacfc40..d0110be1e 100644 --- a/webrtc-jni/src/main/cpp/src/JNI_NativeI420Buffer.cpp +++ b/webrtc-jni/src/main/cpp/src/JNI_NativeI420Buffer.cpp @@ -20,6 +20,7 @@ #include "JavaUtils.h" #include "api/video/i420_buffer.h" +#include "common_video/include/video_frame_buffer.h" #include "libyuv/scale.h" JNIEXPORT jobject JNICALL Java_dev_onvoid_webrtc_media_video_NativeI420Buffer_allocate @@ -64,6 +65,34 @@ JNIEXPORT jobject JNICALL Java_dev_onvoid_webrtc_media_video_NativeI420Buffer_co } } +JNIEXPORT jobject JNICALL Java_dev_onvoid_webrtc_media_video_NativeI420Buffer_crop +(JNIEnv * env, jobject caller, jint cropX, jint cropY, jint cropW, jint cropH) +{ + webrtc::I420BufferInterface * buffer = GetHandle(env, caller); + CHECK_HANDLEV(buffer, nullptr); + + try { + webrtc::scoped_refptr source(buffer); + + // The cropped buffer points into the source, which it keeps alive until it is gone itself. + webrtc::scoped_refptr cropped = webrtc::WrapI420Buffer(cropW, cropH, + source->DataY() + cropX + cropY * source->StrideY(), source->StrideY(), + source->DataU() + cropX / 2 + cropY / 2 * source->StrideU(), source->StrideU(), + source->DataV() + cropX / 2 + cropY / 2 * source->StrideV(), source->StrideV(), + [source]() {}); + + jni::JavaLocalRef jBuffer = jni::I420Buffer::toJava(env, cropped); + + cropped->AddRef(); + + return jBuffer.release(); + } + catch (...) { + ThrowCxxJavaException(env); + return nullptr; + } +} + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_video_NativeI420Buffer_cropAndScale (JNIEnv * env, jclass caller, jobject jSrcY, jint srcStrideY, jobject jSrcU, jint srcStrideU, jobject jSrcV, jint srcStrideV, jint cropX, jint cropY, jint cropW, jint cropH, diff --git a/webrtc-jni/src/main/cpp/src/api/VideoFrame.cpp b/webrtc-jni/src/main/cpp/src/api/VideoFrame.cpp index 24f3ec502..5e5c6a531 100644 --- a/webrtc-jni/src/main/cpp/src/api/VideoFrame.cpp +++ b/webrtc-jni/src/main/cpp/src/api/VideoFrame.cpp @@ -17,6 +17,8 @@ #include "api/VideoFrame.h" #include "JavaObject.h" #include "JavaUtils.h" +#include "JavaRuntimeException.h" +#include "JavaWrappedException.h" #include "JNI_WebRTC.h" #include "api/video/i420_buffer.h" @@ -62,6 +64,13 @@ namespace jni */ webrtc::I420BufferInterface * i420Buffer = GetHandle(env, jBuffer); + + // A buffer that has been released for good has no pixels left. + if (i420Buffer == nullptr) { + throw JavaWrappedException(JavaLocalRef(env, + JavaRuntimeException(env, "The buffer of the VideoFrame has been released"))); + } + webrtc::scoped_refptr buffer(i420Buffer); return webrtc::VideoFrame::Builder() @@ -107,6 +116,11 @@ namespace jni if (env->IsInstanceOf(javaBuffer, nativeClass->cls)) { webrtc::I420BufferInterface * buffer = GetHandle(env, javaBuffer); + // Released for good, so there is nothing to read. + if (buffer == nullptr) { + return nullptr; + } + return webrtc::scoped_refptr(buffer); } @@ -154,9 +168,14 @@ namespace jni { const auto javaClass = JavaClasses::get(env); - jobject yBuffer = env->NewDirectByteBuffer(const_cast(buffer->DataY()), static_cast(buffer->StrideY()) * buffer->height()); - jobject uBuffer = env->NewDirectByteBuffer(const_cast(buffer->DataU()), static_cast(buffer->StrideU()) * buffer->ChromaHeight()); - jobject vBuffer = env->NewDirectByteBuffer(const_cast(buffer->DataV()), static_cast(buffer->StrideV()) * buffer->ChromaHeight()); + // The last row ends at the width: a cropped buffer has no padding behind it that belongs to it. + auto planeSize = [](int stride, int width, int height) { + return static_cast(stride) * (height - 1) + width; + }; + + jobject yBuffer = env->NewDirectByteBuffer(const_cast(buffer->DataY()), planeSize(buffer->StrideY(), buffer->width(), buffer->height())); + jobject uBuffer = env->NewDirectByteBuffer(const_cast(buffer->DataU()), planeSize(buffer->StrideU(), buffer->ChromaWidth(), buffer->ChromaHeight())); + jobject vBuffer = env->NewDirectByteBuffer(const_cast(buffer->DataV()), planeSize(buffer->StrideV(), buffer->ChromaWidth(), buffer->ChromaHeight())); jobject jBuffer = env->NewObject(javaClass->cls, javaClass->ctor, buffer->width(), buffer->height(), yBuffer, buffer->StrideY(), uBuffer, buffer->StrideU(), vBuffer, buffer->StrideV()); diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/media/audio/AudioResampler.java b/webrtc/src/main/java/dev/onvoid/webrtc/media/audio/AudioResampler.java index 192f9eb68..c658530f0 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/media/audio/AudioResampler.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/media/audio/AudioResampler.java @@ -19,6 +19,7 @@ import static java.util.Objects.requireNonNull; import dev.onvoid.webrtc.internal.DisposableNativeObject; +import dev.onvoid.webrtc.internal.NativeLoader; /** * Audio sampling rate converter. This resampler operates on audio frames of 10 @@ -28,6 +29,24 @@ */ public class AudioResampler extends DisposableNativeObject { + /** The most samples of one channel in 10 ms that WebRTC resamples, at 384 kHz. */ + private static final int MAX_SAMPLES_PER_CHANNEL = 3840; + + /** The most channels that WebRTC resamples at once. */ + private static final int MAX_CHANNELS = 8; + + /** Bytes of a 16-bit PCM sample. */ + private static final int BYTES_PER_SAMPLE = 2; + + static { + try { + NativeLoader.loadLibrary("webrtc-java"); + } + catch (Exception e) { + throw new RuntimeException("Load library 'webrtc-java' failed", e); + } + } + /** * Creates a new {@code AudioResampler} without sampling parameters. */ @@ -44,7 +63,14 @@ public AudioResampler() { * @param channels The number of audio channels to resample. */ public AudioResampler(int sourceSampleRate, int targetSampleRate, int channels) { - initialize(getSamplesPerChannel(sourceSampleRate), getSamplesPerChannel(targetSampleRate), channels); + int srcSamplesPerChannel = getSamplesPerChannel(sourceSampleRate); + int dstSamplesPerChannel = getSamplesPerChannel(targetSampleRate); + + checkSamplesPerChannel(srcSamplesPerChannel, "source"); + checkSamplesPerChannel(dstSamplesPerChannel, "destination"); + checkChannels(channels); + + initialize(srcSamplesPerChannel, dstSamplesPerChannel, channels); } /** @@ -59,20 +85,24 @@ public AudioResampler(int sourceSampleRate, int targetSampleRate, int channels) * @param dstSamplesPerChannel The number of samples per destination channel. * @param channels The number of audio channels to resample. * - * @return The number of converted audio samples. + * @return The number of converted audio samples per channel. + * + * @throws IllegalArgumentException if a sample count or the channel count is out of range, or if + * an array holds fewer than {@code samplesPerChannel * channels} + * samples. */ public int resample(byte[] samplesIn, int srcSamplesPerChannel, byte[] samplesOut, int dstSamplesPerChannel, int channels) { requireNonNull(samplesIn); requireNonNull(samplesOut); - final int maxSamplesOut = samplesOut.length / 2; // 16-bit PCM sample - - if (dstSamplesPerChannel > maxSamplesOut) { - throw new IllegalArgumentException("Insufficient samples output length"); - } + checkSamplesPerChannel(srcSamplesPerChannel, "source"); + checkSamplesPerChannel(dstSamplesPerChannel, "destination"); + checkChannels(channels); + checkLength(samplesIn, srcSamplesPerChannel, channels, "input"); + checkLength(samplesOut, dstSamplesPerChannel, channels, "output"); - return resampleInternal(samplesIn, srcSamplesPerChannel, samplesOut, maxSamplesOut, channels); + return resampleInternal(samplesIn, srcSamplesPerChannel, samplesOut, dstSamplesPerChannel, channels); } @Override @@ -95,4 +125,28 @@ private native int resampleInternal(byte[] samplesIn, int srcSamplesPerChannel, public static int getSamplesPerChannel(int sampleRate) { return sampleRate / 100; } + + private static void checkSamplesPerChannel(int samplesPerChannel, String name) { + if (samplesPerChannel <= 0 || samplesPerChannel > MAX_SAMPLES_PER_CHANNEL) { + throw new IllegalArgumentException("Invalid number of " + name + " samples per channel: " + + samplesPerChannel + ", must be between 1 and " + MAX_SAMPLES_PER_CHANNEL); + } + } + + private static void checkChannels(int channels) { + if (channels <= 0 || channels > MAX_CHANNELS) { + throw new IllegalArgumentException("Invalid number of channels: " + channels + + ", must be between 1 and " + MAX_CHANNELS); + } + } + + private static void checkLength(byte[] samples, int samplesPerChannel, int channels, String name) { + // Both counts are bounded, so this cannot overflow. + final int required = samplesPerChannel * channels * BYTES_PER_SAMPLE; + + if (samples.length < required) { + throw new IllegalArgumentException("Insufficient samples " + name + " length: need " + + required + " bytes, but was " + samples.length); + } + } } diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/media/video/NativeI420Buffer.java b/webrtc/src/main/java/dev/onvoid/webrtc/media/video/NativeI420Buffer.java index a06905612..556c3d4f4 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/media/video/NativeI420Buffer.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/media/video/NativeI420Buffer.java @@ -131,49 +131,26 @@ private static native NativeI420Buffer copy(int width, int height, ByteBuffer da int strideU, ByteBuffer dataV, int strideV); /** - * Wraps existing ByteBuffers into NativeI420Buffer object without copying the - * contents. + * Crops this buffer without copying. The cropped buffer shares the pixels of this one and holds a + * reference to it, so it stays valid when this buffer is released. */ - private static I420Buffer wrap(int width, int height, ByteBuffer dataY, int strideY, ByteBuffer dataU, - int strideU, ByteBuffer dataV, int strideV) { - if (dataY == null || dataU == null || dataV == null) { - throw new IllegalArgumentException("Data buffers cannot be null"); + private native NativeI420Buffer crop(int cropX, int cropY, int cropWidth, int cropHeight); + + private static VideoFrameBuffer cropAndScale(final NativeI420Buffer buffer, int cropX, int cropY, + int cropWidth, int cropHeight, int scaleWidth, int scaleHeight) { + if (cropX < 0 || cropY < 0 || cropWidth <= 0 || cropHeight <= 0 + || cropWidth > buffer.getWidth() - cropX || cropHeight > buffer.getHeight() - cropY) { + throw new IllegalArgumentException("Crop rectangle " + cropWidth + "x" + cropHeight + " at (" + + cropX + ", " + cropY + ") does not fit the " + buffer.getWidth() + "x" + + buffer.getHeight() + " buffer"); } - if (!dataY.isDirect() || !dataU.isDirect() || !dataV.isDirect()) { - throw new IllegalArgumentException("Data buffers must be direct byte buffers"); + if (scaleWidth <= 0 || scaleHeight <= 0) { + throw new IllegalArgumentException("Invalid scale size " + scaleWidth + "x" + scaleHeight); } - // Slice the buffers to prevent external modifications to the position / limit - // of the buffer. - // Note that this doesn't protect the contents of the buffers from modifications. - dataY = dataY.slice(); - dataU = dataU.slice(); - dataV = dataV.slice(); - - final int chromaWidth = (width + 1) / 2; - final int chromaHeight = (height + 1) / 2; - - checkCapacity(dataY, width, height, strideY); - checkCapacity(dataU, chromaWidth, chromaHeight, strideU); - checkCapacity(dataV, chromaWidth, chromaHeight, strideV); - - return new NativeI420Buffer(width, height, dataY, strideY, dataU, strideU, dataV, strideV); - } - - private static VideoFrameBuffer cropAndScale(final I420Buffer buffer, int cropX, int cropY, - int cropWidth, int cropHeight, int scaleWidth, int scaleHeight) { if (cropWidth == scaleWidth && cropHeight == scaleHeight) { // No scaling. - ByteBuffer dataY = buffer.getDataY(); - ByteBuffer dataU = buffer.getDataU(); - ByteBuffer dataV = buffer.getDataV(); - - dataY.position(cropX + cropY * buffer.getStrideY()); - dataU.position(cropX / 2 + cropY / 2 * buffer.getStrideU()); - dataV.position(cropX / 2 + cropY / 2 * buffer.getStrideV()); - - return wrap(scaleWidth, scaleHeight, dataY.slice(), buffer.getStrideY(), dataU.slice(), - buffer.getStrideU(), dataV.slice(), buffer.getStrideV()); + return buffer.crop(cropX, cropY, cropWidth, cropHeight); } I420Buffer newBuffer = allocate(scaleWidth, scaleHeight); @@ -186,16 +163,6 @@ private static VideoFrameBuffer cropAndScale(final I420Buffer buffer, int cropX, return newBuffer; } - private static void checkCapacity(ByteBuffer data, int width, int height, int stride) { - // The last row does not necessarily need padding. - final int minCapacity = stride * (height - 1) + width; - - if (data.capacity() < minCapacity) { - throw new IllegalArgumentException("Buffer must be at least " + minCapacity + - " bytes, but was " + data.capacity()); - } - } - private static native void cropAndScale(ByteBuffer srcY, int srcStrideY, ByteBuffer srcU, int srcStrideU, ByteBuffer srcV, int srcStrideV, int cropX, int cropY, int cropWidth, int cropHeight, ByteBuffer dstY, int dstStrideY, ByteBuffer dstU, diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/AudioResamplerTest.java b/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/AudioResamplerTest.java index 29e1131a9..a53e191bc 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/AudioResamplerTest.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/AudioResamplerTest.java @@ -17,8 +17,11 @@ package dev.onvoid.webrtc.media.audio; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import java.util.Arrays; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -46,6 +49,75 @@ void targetBufferUnderflow() { assertThrows(IllegalArgumentException.class, () -> resample(resampler, buffer)); } + @Test + void stereoTargetUnderflow() { + // Room for all samples of one channel, but not for both. + SampleBuffer buffer = new SampleBuffer(48000, 48000, 2); + buffer.setTargetBufferSize(buffer.frameSizeOut / 2); + + assertThrows(IllegalArgumentException.class, () -> resample(resampler, buffer)); + } + + @Test + void sourceUnderflow() { + SampleBuffer buffer = new SampleBuffer(48000, 16000, 2); + buffer.src = new byte[buffer.frameSizeIn - 2]; + + assertThrows(IllegalArgumentException.class, () -> resample(resampler, buffer)); + } + + @Test + void invalidParameters() { + SampleBuffer buffer = new SampleBuffer(48000, 16000, 1); + + assertThrows(IllegalArgumentException.class, + () -> resampler.resample(buffer.src, buffer.nSamplesIn, buffer.dst, buffer.nSamplesOut, 0)); + assertThrows(IllegalArgumentException.class, + () -> resampler.resample(buffer.src, buffer.nSamplesIn, buffer.dst, buffer.nSamplesOut, 9)); + assertThrows(IllegalArgumentException.class, + () -> resampler.resample(buffer.src, buffer.nSamplesIn, buffer.dst, 0, 1)); + assertThrows(IllegalArgumentException.class, + () -> resampler.resample(buffer.src, -1, buffer.dst, buffer.nSamplesOut, 1)); + assertThrows(IllegalArgumentException.class, () -> new AudioResampler(48000, 16000, 0)); + assertThrows(IllegalArgumentException.class, () -> new AudioResampler(0, 16000, 1)); + } + + @Test + void oversizedTarget() { + // The resampler writes the requested samples, not as many as the array holds. + SampleBuffer buffer = new SampleBuffer(48000, 16000, 1); + buffer.setTargetBufferSize(buffer.frameSizeOut * 4); + + assertEquals(buffer.nSamplesOut, resample(resampler, buffer)); + assertUntouchedAfter(buffer.dst, buffer.frameSizeOut); + } + + @Test + void stereo() { + SampleBuffer buffer = new SampleBuffer(48000, 16000, 2); + buffer.setTargetBufferSize(buffer.frameSizeOut * 2); + + // A loud left and a silent right channel. + for (int i = 0; i < buffer.nSamplesIn; i++) { + buffer.src[i * 4 + 1] = 0x40; + } + + int result = 0; + + // The resampler has a delay, so the signal shows only after a few frames. + for (int i = 0; i < 5; i++) { + result = resample(resampler, buffer); + } + + assertEquals(buffer.nSamplesOut, result); + assertUntouchedAfter(buffer.dst, buffer.frameSizeOut); + + int last = buffer.nSamplesOut - 1; + + assertNotEquals(0, sample(buffer.dst, last * 2)); + assertEquals(0, sample(buffer.dst, last * 2 + 1)); + } + @Test void constructorParameters() { SampleBuffer buffer = new SampleBuffer(48000, 44100, 2); @@ -77,13 +149,25 @@ void upSample() { } private static int resample(AudioResampler resampler, SampleBuffer buffer) { - return resampler.resample(buffer.src, buffer.nSamplesIn, buffer.dst, buffer.nSamplesOut, 1); + return resampler.resample(buffer.src, buffer.nSamplesIn, buffer.dst, buffer.nSamplesOut, buffer.channels); } + private static short sample(byte[] samples, int index) { + // Little-endian 16-bit PCM. + return (short) ((samples[index * 2] & 0xFF) | (samples[index * 2 + 1] << 8)); + } + + private static void assertUntouchedAfter(byte[] samples, int length) { + for (int i = length; i < samples.length; i++) { + assertEquals(SampleBuffer.UNTOUCHED, samples[i], "Byte " + i + " was written"); + } + } private static class SampleBuffer { + static final byte UNTOUCHED = 0x55; + final int bytesPerFrame = 2; final int channels; @@ -108,8 +192,8 @@ private static class SampleBuffer { nSamplesIn = AudioResampler.getSamplesPerChannel(sampleRateIn); // 10 ms frame nSamplesOut = AudioResampler.getSamplesPerChannel(sampleRateOut); - frameSizeIn = nSamplesIn * bytesPerFrame; - frameSizeOut = nSamplesOut * bytesPerFrame; + frameSizeIn = nSamplesIn * channels * bytesPerFrame; + frameSizeOut = nSamplesOut * channels * bytesPerFrame; src = new byte[frameSizeIn]; dst = new byte[frameSizeOut]; @@ -117,6 +201,8 @@ private static class SampleBuffer { void setTargetBufferSize(int size) { dst = new byte[size]; + + Arrays.fill(dst, UNTOUCHED); } } } diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/media/video/NativeI420BufferTest.java b/webrtc/src/test/java/dev/onvoid/webrtc/media/video/NativeI420BufferTest.java new file mode 100644 index 000000000..6defa8f60 --- /dev/null +++ b/webrtc/src/test/java/dev/onvoid/webrtc/media/video/NativeI420BufferTest.java @@ -0,0 +1,128 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.media.video; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import dev.onvoid.webrtc.TestBase; + +import org.junit.jupiter.api.Test; + +class NativeI420BufferTest extends TestBase { + + @Test + void cropOutlivesSource() { + NativeI420Buffer source = NativeI420Buffer.allocate(8, 8); + fillY(source); + + NativeI420Buffer cropped = assertInstanceOf(NativeI420Buffer.class, + source.cropAndScale(2, 4, 4, 4, 4, 4)); + + // The crop holds the pixels of the source, which must stay valid without it. + source.release(); + + cropped.retain(); + cropped.release(); + + ByteBuffer y = cropped.getDataY(); + + for (int row = 0; row < 4; row++) { + for (int col = 0; col < 4; col++) { + assertEquals(yValue(row + 4, col + 2), y.get(row * cropped.getStrideY() + col)); + } + } + + // The native side reads the crop as well. + CustomVideoSource videoSource = new CustomVideoSource(); + VideoTrack videoTrack = factory.createVideoTrack("videoTrack", videoSource); + List widths = new ArrayList<>(); + VideoTrackSink sink = frame -> widths.add(frame.buffer.getWidth()); + + videoTrack.addSink(sink); + + VideoFrame frame = new VideoFrame(cropped, 0); + videoSource.pushFrame(frame); + frame.release(); + + videoTrack.removeSink(sink); + videoTrack.dispose(); + videoSource.dispose(); + + assertEquals(Collections.singletonList(4), widths); + } + + @Test + void cropEndsAtLastPixel() { + NativeI420Buffer source = NativeI420Buffer.allocate(8, 8); + I420Buffer cropped = source.cropAndScale(2, 0, 4, 8, 4, 8).toI420(); + + // The last row of a plane ends at the crop width, not at the stride. + assertEquals(7 * cropped.getStrideY() + 4, cropped.getDataY().capacity()); + assertEquals(3 * cropped.getStrideU() + 2, cropped.getDataU().capacity()); + assertEquals(3 * cropped.getStrideV() + 2, cropped.getDataV().capacity()); + + ((NativeI420Buffer) cropped).release(); + source.release(); + } + + @Test + void cropOutsideBuffer() { + NativeI420Buffer source = NativeI420Buffer.allocate(8, 8); + + assertThrows(IllegalArgumentException.class, () -> source.cropAndScale(6, 0, 4, 4, 4, 4)); + assertThrows(IllegalArgumentException.class, () -> source.cropAndScale(0, -1, 4, 4, 4, 4)); + assertThrows(IllegalArgumentException.class, () -> source.cropAndScale(0, 0, 0, 4, 0, 4)); + assertThrows(IllegalArgumentException.class, () -> source.cropAndScale(0, 0, 4, 4, 0, 2)); + + source.release(); + } + + @Test + void pushReleasedBuffer() { + CustomVideoSource videoSource = new CustomVideoSource(); + NativeI420Buffer buffer = NativeI420Buffer.allocate(8, 8); + + buffer.release(); + + VideoFrame frame = new VideoFrame(buffer, 0); + + assertThrows(RuntimeException.class, () -> videoSource.pushFrame(frame)); + + videoSource.dispose(); + } + + private static void fillY(I420Buffer buffer) { + ByteBuffer y = buffer.getDataY(); + + for (int row = 0; row < buffer.getHeight(); row++) { + for (int col = 0; col < buffer.getWidth(); col++) { + y.put(row * buffer.getStrideY() + col, yValue(row, col)); + } + } + } + + private static byte yValue(int row, int col) { + return (byte) (row * 16 + col); + } +}