blob: c9ed406c1ad1cca8d171b018ec61a0117f1929f9 [file] [log] [blame]
/*
* Copyright 2017 The WebRTC project authors. All Rights Reserved.
*
* Use of this source code is governed by a BSD-style license
* that can be found in the LICENSE file in the root of the source
* tree. An additional intellectual property rights grant can be found
* in the file PATENTS. All contributing project authors may
* be found in the AUTHORS file in the root of the source tree.
*/
package org.webrtc;
import android.annotation.TargetApi;
import android.media.MediaCodec;
import android.media.MediaCodecInfo.CodecCapabilities;
import android.media.MediaFormat;
import android.os.SystemClock;
import android.view.Surface;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.TimeUnit;
import javax.annotation.Nullable;
import org.webrtc.ThreadUtils.ThreadChecker;
/**
* Android hardware video decoder.
*/
@TargetApi(16)
@SuppressWarnings("deprecation")
// Cannot support API 16 without using deprecated methods.
// TODO(sakal): Rename to MediaCodecVideoDecoder once the deprecated implementation is removed.
class AndroidVideoDecoder implements VideoDecoder, VideoSink {
private static final String TAG = "AndroidVideoDecoder";
// TODO(magjed): Use MediaFormat.KEY_* constants when part of the public API.
private static final String MEDIA_FORMAT_KEY_STRIDE = "stride";
private static final String MEDIA_FORMAT_KEY_SLICE_HEIGHT = "slice-height";
private static final String MEDIA_FORMAT_KEY_CROP_LEFT = "crop-left";
private static final String MEDIA_FORMAT_KEY_CROP_RIGHT = "crop-right";
private static final String MEDIA_FORMAT_KEY_CROP_TOP = "crop-top";
private static final String MEDIA_FORMAT_KEY_CROP_BOTTOM = "crop-bottom";
// MediaCodec.release() occasionally hangs. Release stops waiting and reports failure after
// this timeout.
private static final int MEDIA_CODEC_RELEASE_TIMEOUT_MS = 5000;
// WebRTC queues input frames quickly in the beginning on the call. Wait for input buffers with a
// long timeout (500 ms) to prevent this from causing the codec to return an error.
private static final int DEQUEUE_INPUT_TIMEOUT_US = 500000;
// Dequeuing an output buffer will block until a buffer is available (up to 100 milliseconds).
// If this timeout is exceeded, the output thread will unblock and check if the decoder is still
// running. If it is, it will block on dequeue again. Otherwise, it will stop and release the
// MediaCodec.
private static final int DEQUEUE_OUTPUT_BUFFER_TIMEOUT_US = 100000;
private final MediaCodecWrapperFactory mediaCodecWrapperFactory;
private final String codecName;
private final VideoCodecType codecType;
private static class FrameInfo {
final long decodeStartTimeMs;
final int rotation;
FrameInfo(long decodeStartTimeMs, int rotation) {
this.decodeStartTimeMs = decodeStartTimeMs;
this.rotation = rotation;
}
}
private final BlockingDeque<FrameInfo> frameInfos;
private int colorFormat;
// Output thread runs a loop which polls MediaCodec for decoded output buffers. It reformats
// those buffers into VideoFrames and delivers them to the callback. Variable is set on decoder
// thread and is immutable while the codec is running.
@Nullable private Thread outputThread;
// Checker that ensures work is run on the output thread.
private ThreadChecker outputThreadChecker;
// Checker that ensures work is run on the decoder thread. The decoder thread is owned by the
// caller and must be used to call initDecode, decode, and release.
private ThreadChecker decoderThreadChecker;
private volatile boolean running;
@Nullable private volatile Exception shutdownException;
// Dimensions (width, height, stride, and sliceHeight) may be accessed by either the decode thread
// or the output thread. Accesses should be protected with this lock.
private final Object dimensionLock = new Object();
private int width;
private int height;
private int stride;
private int sliceHeight;
// Whether the decoder has finished the first frame. The codec may not change output dimensions
// after delivering the first frame. Only accessed on the output thread while the decoder is
// running.
private boolean hasDecodedFirstFrame;
// Whether the decoder has seen a key frame. The first frame must be a key frame. Only accessed
// on the decoder thread.
private boolean keyFrameRequired;
private final @Nullable EglBase.Context sharedContext;
// Valid and immutable while the decoder is running.
@Nullable private SurfaceTextureHelper surfaceTextureHelper;
@Nullable private Surface surface;
private static class DecodedTextureMetadata {
final long presentationTimestampUs;
final Integer decodeTimeMs;
DecodedTextureMetadata(long presentationTimestampUs, Integer decodeTimeMs) {
this.presentationTimestampUs = presentationTimestampUs;
this.decodeTimeMs = decodeTimeMs;
}
}
// Metadata for the last frame rendered to the texture.
private final Object renderedTextureMetadataLock = new Object();
@Nullable private DecodedTextureMetadata renderedTextureMetadata;
// Decoding proceeds asynchronously. This callback returns decoded frames to the caller. Valid
// and immutable while the decoder is running.
@Nullable private Callback callback;
// Valid and immutable while the decoder is running.
@Nullable private MediaCodecWrapper codec;
AndroidVideoDecoder(MediaCodecWrapperFactory mediaCodecWrapperFactory, String codecName,
VideoCodecType codecType, int colorFormat, @Nullable EglBase.Context sharedContext) {
if (!isSupportedColorFormat(colorFormat)) {
throw new IllegalArgumentException("Unsupported color format: " + colorFormat);
}
Logging.d(TAG,
"ctor name: " + codecName + " type: " + codecType + " color format: " + colorFormat
+ " context: " + sharedContext);
this.mediaCodecWrapperFactory = mediaCodecWrapperFactory;
this.codecName = codecName;
this.codecType = codecType;
this.colorFormat = colorFormat;
this.sharedContext = sharedContext;
this.frameInfos = new LinkedBlockingDeque<>();
}
@Override
public VideoCodecStatus initDecode(Settings settings, Callback callback) {
this.decoderThreadChecker = new ThreadChecker();
this.callback = callback;
if (sharedContext != null) {
surfaceTextureHelper = createSurfaceTextureHelper();
surface = new Surface(surfaceTextureHelper.getSurfaceTexture());
surfaceTextureHelper.startListening(this);
}
return initDecodeInternal(settings.width, settings.height);
}
// Internal variant is used when restarting the codec due to reconfiguration.
private VideoCodecStatus initDecodeInternal(int width, int height) {
decoderThreadChecker.checkIsOnValidThread();
Logging.d(TAG,
"initDecodeInternal name: " + codecName + " type: " + codecType + " width: " + width
+ " height: " + height);
if (outputThread != null) {
Logging.e(TAG, "initDecodeInternal called while the codec is already running");
return VideoCodecStatus.FALLBACK_SOFTWARE;
}
// Note: it is not necessary to initialize dimensions under the lock, since the output thread
// is not running.
this.width = width;
this.height = height;
stride = width;
sliceHeight = height;
hasDecodedFirstFrame = false;
keyFrameRequired = true;
try {
codec = mediaCodecWrapperFactory.createByCodecName(codecName);
} catch (IOException | IllegalArgumentException e) {
Logging.e(TAG, "Cannot create media decoder " + codecName);
return VideoCodecStatus.FALLBACK_SOFTWARE;
}
try {
MediaFormat format = MediaFormat.createVideoFormat(codecType.mimeType(), width, height);
if (sharedContext == null) {
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, colorFormat);
}
codec.configure(format, surface, null, 0);
codec.start();
} catch (IllegalStateException e) {
Logging.e(TAG, "initDecode failed", e);
release();
return VideoCodecStatus.FALLBACK_SOFTWARE;
}
running = true;
outputThread = createOutputThread();
outputThread.start();
Logging.d(TAG, "initDecodeInternal done");
return VideoCodecStatus.OK;
}
@Override
public VideoCodecStatus decode(EncodedImage frame, DecodeInfo info) {
decoderThreadChecker.checkIsOnValidThread();
if (codec == null || callback == null) {
Logging.d(TAG, "decode uninitalized, codec: " + (codec != null) + ", callback: " + callback);
return VideoCodecStatus.UNINITIALIZED;
}
if (frame.buffer == null) {
Logging.e(TAG, "decode() - no input data");
return VideoCodecStatus.ERR_PARAMETER;
}
int size = frame.buffer.remaining();
if (size == 0) {
Logging.e(TAG, "decode() - input buffer empty");
return VideoCodecStatus.ERR_PARAMETER;
}
// Load dimensions from shared memory under the dimension lock.
final int width;
final int height;
synchronized (dimensionLock) {
width = this.width;
height = this.height;
}
// Check if the resolution changed and reset the codec if necessary.
if (frame.encodedWidth * frame.encodedHeight > 0
&& (frame.encodedWidth != width || frame.encodedHeight != height)) {
VideoCodecStatus status = reinitDecode(frame.encodedWidth, frame.encodedHeight);
if (status != VideoCodecStatus.OK) {
return status;
}
}
if (keyFrameRequired) {
// Need to process a key frame first.
if (frame.frameType != EncodedImage.FrameType.VideoFrameKey) {
Logging.e(TAG, "decode() - key frame required first");
return VideoCodecStatus.NO_OUTPUT;
}
if (!frame.completeFrame) {
Logging.e(TAG, "decode() - complete frame required first");
return VideoCodecStatus.NO_OUTPUT;
}
}
int index;
try {
index = codec.dequeueInputBuffer(DEQUEUE_INPUT_TIMEOUT_US);
} catch (IllegalStateException e) {
Logging.e(TAG, "dequeueInputBuffer failed", e);
return VideoCodecStatus.ERROR;
}
if (index < 0) {
// Decoder is falling behind. No input buffers available.
// The decoder can't simply drop frames; it might lose a key frame.
Logging.e(TAG, "decode() - no HW buffers available; decoder falling behind");
return VideoCodecStatus.ERROR;
}
ByteBuffer buffer;
try {
buffer = codec.getInputBuffers()[index];
} catch (IllegalStateException e) {
Logging.e(TAG, "getInputBuffers failed", e);
return VideoCodecStatus.ERROR;
}
if (buffer.capacity() < size) {
Logging.e(TAG, "decode() - HW buffer too small");
return VideoCodecStatus.ERROR;
}
buffer.put(frame.buffer);
frameInfos.offer(new FrameInfo(SystemClock.elapsedRealtime(), frame.rotation));
try {
codec.queueInputBuffer(index, 0 /* offset */, size,
TimeUnit.NANOSECONDS.toMicros(frame.captureTimeNs), 0 /* flags */);
} catch (IllegalStateException e) {
Logging.e(TAG, "queueInputBuffer failed", e);
frameInfos.pollLast();
return VideoCodecStatus.ERROR;
}
if (keyFrameRequired) {
keyFrameRequired = false;
}
return VideoCodecStatus.OK;
}
@Override
public boolean getPrefersLateDecoding() {
return true;
}
@Override
public String getImplementationName() {
return codecName;
}
@Override
public VideoCodecStatus release() {
// TODO(sakal): This is not called on the correct thread but is still called synchronously.
// Re-enable the check once this is called on the correct thread.
// decoderThreadChecker.checkIsOnValidThread();
Logging.d(TAG, "release");
VideoCodecStatus status = releaseInternal();
if (surface != null) {
releaseSurface();
surface = null;
surfaceTextureHelper.stopListening();
surfaceTextureHelper.dispose();
surfaceTextureHelper = null;
}
synchronized (renderedTextureMetadataLock) {
renderedTextureMetadata = null;
}
callback = null;
frameInfos.clear();
return status;
}
// Internal variant is used when restarting the codec due to reconfiguration.
private VideoCodecStatus releaseInternal() {
if (!running) {
Logging.d(TAG, "release: Decoder is not running.");
return VideoCodecStatus.OK;
}
try {
// The outputThread actually stops and releases the codec once running is false.
running = false;
if (!ThreadUtils.joinUninterruptibly(outputThread, MEDIA_CODEC_RELEASE_TIMEOUT_MS)) {
// Log an exception to capture the stack trace and turn it into a TIMEOUT error.
Logging.e(TAG, "Media decoder release timeout", new RuntimeException());
return VideoCodecStatus.TIMEOUT;
}
if (shutdownException != null) {
// Log the exception and turn it into an error. Wrap the exception in a new exception to
// capture both the output thread's stack trace and this thread's stack trace.
Logging.e(TAG, "Media decoder release error", new RuntimeException(shutdownException));
shutdownException = null;
return VideoCodecStatus.ERROR;
}
} finally {
codec = null;
outputThread = null;
}
return VideoCodecStatus.OK;
}
private VideoCodecStatus reinitDecode(int newWidth, int newHeight) {
decoderThreadChecker.checkIsOnValidThread();
VideoCodecStatus status = releaseInternal();
if (status != VideoCodecStatus.OK) {
return status;
}
return initDecodeInternal(newWidth, newHeight);
}
private Thread createOutputThread() {
return new Thread("AndroidVideoDecoder.outputThread") {
@Override
public void run() {
outputThreadChecker = new ThreadChecker();
while (running) {
deliverDecodedFrame();
}
releaseCodecOnOutputThread();
}
};
}
// Visible for testing.
protected void deliverDecodedFrame() {
outputThreadChecker.checkIsOnValidThread();
try {
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
// Block until an output buffer is available (up to 100 milliseconds). If the timeout is
// exceeded, deliverDecodedFrame() will be called again on the next iteration of the output
// thread's loop. Blocking here prevents the output thread from busy-waiting while the codec
// is idle.
int result = codec.dequeueOutputBuffer(info, DEQUEUE_OUTPUT_BUFFER_TIMEOUT_US);
if (result == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
reformat(codec.getOutputFormat());
return;
}
if (result < 0) {
Logging.v(TAG, "dequeueOutputBuffer returned " + result);
return;
}
FrameInfo frameInfo = frameInfos.poll();
Integer decodeTimeMs = null;
int rotation = 0;
if (frameInfo != null) {
decodeTimeMs = (int) (SystemClock.elapsedRealtime() - frameInfo.decodeStartTimeMs);
rotation = frameInfo.rotation;
}
hasDecodedFirstFrame = true;
if (surfaceTextureHelper != null) {
deliverTextureFrame(result, info, rotation, decodeTimeMs);
} else {
deliverByteFrame(result, info, rotation, decodeTimeMs);
}
} catch (IllegalStateException e) {
Logging.e(TAG, "deliverDecodedFrame failed", e);
}
}
private void deliverTextureFrame(final int index, final MediaCodec.BufferInfo info,
final int rotation, final Integer decodeTimeMs) {
// Load dimensions from shared memory under the dimension lock.
final int width;
final int height;
synchronized (dimensionLock) {
width = this.width;
height = this.height;
}
synchronized (renderedTextureMetadataLock) {
if (renderedTextureMetadata != null) {
codec.releaseOutputBuffer(index, false);
return; // We are still waiting for texture for the previous frame, drop this one.
}
surfaceTextureHelper.setTextureSize(width, height);
surfaceTextureHelper.setFrameRotation(rotation);
renderedTextureMetadata = new DecodedTextureMetadata(info.presentationTimeUs, decodeTimeMs);
codec.releaseOutputBuffer(index, /* render= */ true);
}
}
@Override
public void onFrame(VideoFrame frame) {
final VideoFrame newFrame;
final int decodeTimeMs;
final long timestampNs;
synchronized (renderedTextureMetadataLock) {
if (renderedTextureMetadata == null) {
throw new IllegalStateException(
"Rendered texture metadata was null in onTextureFrameAvailable.");
}
timestampNs = renderedTextureMetadata.presentationTimestampUs * 1000;
decodeTimeMs = renderedTextureMetadata.decodeTimeMs;
renderedTextureMetadata = null;
}
// Change timestamp of frame.
final VideoFrame frameWithModifiedTimeStamp =
new VideoFrame(frame.getBuffer(), frame.getRotation(), timestampNs);
callback.onDecodedFrame(frameWithModifiedTimeStamp, decodeTimeMs, null /* qp */);
}
private void deliverByteFrame(
int result, MediaCodec.BufferInfo info, int rotation, Integer decodeTimeMs) {
// Load dimensions from shared memory under the dimension lock.
int width, height, stride, sliceHeight;
synchronized (dimensionLock) {
width = this.width;
height = this.height;
stride = this.stride;
sliceHeight = this.sliceHeight;
}
// Output must be at least width * height bytes for Y channel, plus (width / 2) * (height / 2)
// bytes for each of the U and V channels.
if (info.size < width * height * 3 / 2) {
Logging.e(TAG, "Insufficient output buffer size: " + info.size);
return;
}
if (info.size < stride * height * 3 / 2 && sliceHeight == height && stride > width) {
// Some codecs (Exynos) report an incorrect stride. Correct it here.
// Expected size == stride * height * 3 / 2. A bit of algebra gives the correct stride as
// 2 * size / (3 * height).
stride = info.size * 2 / (height * 3);
}
ByteBuffer buffer = codec.getOutputBuffers()[result];
buffer.position(info.offset);
buffer.limit(info.offset + info.size);
buffer = buffer.slice();
final VideoFrame.Buffer frameBuffer;
if (colorFormat == CodecCapabilities.COLOR_FormatYUV420Planar) {
frameBuffer = copyI420Buffer(buffer, stride, sliceHeight, width, height);
} else {
// All other supported color formats are NV12.
frameBuffer = copyNV12ToI420Buffer(buffer, stride, sliceHeight, width, height);
}
codec.releaseOutputBuffer(result, /* render= */ false);
long presentationTimeNs = info.presentationTimeUs * 1000;
VideoFrame frame = new VideoFrame(frameBuffer, rotation, presentationTimeNs);
// Note that qp is parsed on the C++ side.
callback.onDecodedFrame(frame, decodeTimeMs, null /* qp */);
frame.release();
}
private VideoFrame.Buffer copyNV12ToI420Buffer(
ByteBuffer buffer, int stride, int sliceHeight, int width, int height) {
// toI420 copies the buffer.
return new NV12Buffer(width, height, stride, sliceHeight, buffer, null /* releaseCallback */)
.toI420();
}
private VideoFrame.Buffer copyI420Buffer(
ByteBuffer buffer, int stride, int sliceHeight, int width, int height) {
if (stride % 2 != 0) {
throw new AssertionError("Stride is not divisible by two: " + stride);
}
// Note that the case with odd |sliceHeight| is handled in a special way.
// The chroma height contained in the payload is rounded down instead of
// up, making it one row less than what we expect in WebRTC. Therefore, we
// have to duplicate the last chroma rows for this case. Also, the offset
// between the Y plane and the U plane is unintuitive for this case. See
// http://bugs.webrtc.org/6651 for more info.
final int chromaWidth = (width + 1) / 2;
final int chromaHeight = (sliceHeight % 2 == 0) ? (height + 1) / 2 : height / 2;
final int uvStride = stride / 2;
final int yPos = 0;
final int yEnd = yPos + stride * height;
final int uPos = yPos + stride * sliceHeight;
final int uEnd = uPos + uvStride * chromaHeight;
final int vPos = uPos + uvStride * sliceHeight / 2;
final int vEnd = vPos + uvStride * chromaHeight;
VideoFrame.I420Buffer frameBuffer = allocateI420Buffer(width, height);
buffer.limit(yEnd);
buffer.position(yPos);
copyPlane(
buffer.slice(), stride, frameBuffer.getDataY(), frameBuffer.getStrideY(), width, height);
buffer.limit(uEnd);
buffer.position(uPos);
copyPlane(buffer.slice(), uvStride, frameBuffer.getDataU(), frameBuffer.getStrideU(),
chromaWidth, chromaHeight);
if (sliceHeight % 2 == 1) {
buffer.position(uPos + uvStride * (chromaHeight - 1)); // Seek to beginning of last full row.
ByteBuffer dataU = frameBuffer.getDataU();
dataU.position(frameBuffer.getStrideU() * chromaHeight); // Seek to beginning of last row.
dataU.put(buffer); // Copy the last row.
}
buffer.limit(vEnd);
buffer.position(vPos);
copyPlane(buffer.slice(), uvStride, frameBuffer.getDataV(), frameBuffer.getStrideV(),
chromaWidth, chromaHeight);
if (sliceHeight % 2 == 1) {
buffer.position(vPos + uvStride * (chromaHeight - 1)); // Seek to beginning of last full row.
ByteBuffer dataV = frameBuffer.getDataV();
dataV.position(frameBuffer.getStrideV() * chromaHeight); // Seek to beginning of last row.
dataV.put(buffer); // Copy the last row.
}
return frameBuffer;
}
private void reformat(MediaFormat format) {
outputThreadChecker.checkIsOnValidThread();
Logging.d(TAG, "Decoder format changed: " + format.toString());
final int newWidth;
final int newHeight;
if (format.containsKey(MEDIA_FORMAT_KEY_CROP_LEFT)
&& format.containsKey(MEDIA_FORMAT_KEY_CROP_RIGHT)
&& format.containsKey(MEDIA_FORMAT_KEY_CROP_BOTTOM)
&& format.containsKey(MEDIA_FORMAT_KEY_CROP_TOP)) {
newWidth = 1 + format.getInteger(MEDIA_FORMAT_KEY_CROP_RIGHT)
- format.getInteger(MEDIA_FORMAT_KEY_CROP_LEFT);
newHeight = 1 + format.getInteger(MEDIA_FORMAT_KEY_CROP_BOTTOM)
- format.getInteger(MEDIA_FORMAT_KEY_CROP_TOP);
} else {
newWidth = format.getInteger(MediaFormat.KEY_WIDTH);
newHeight = format.getInteger(MediaFormat.KEY_HEIGHT);
}
// Compare to existing width, height, and save values under the dimension lock.
synchronized (dimensionLock) {
if (hasDecodedFirstFrame && (width != newWidth || height != newHeight)) {
stopOnOutputThread(new RuntimeException("Unexpected size change. Configured " + width + "*"
+ height + ". New " + newWidth + "*" + newHeight));
return;
}
width = newWidth;
height = newHeight;
}
// Note: texture mode ignores colorFormat. Hence, if the texture helper is non-null, skip
// color format updates.
if (surfaceTextureHelper == null && format.containsKey(MediaFormat.KEY_COLOR_FORMAT)) {
colorFormat = format.getInteger(MediaFormat.KEY_COLOR_FORMAT);
Logging.d(TAG, "Color: 0x" + Integer.toHexString(colorFormat));
if (!isSupportedColorFormat(colorFormat)) {
stopOnOutputThread(new IllegalStateException("Unsupported color format: " + colorFormat));
return;
}
}
// Save stride and sliceHeight under the dimension lock.
synchronized (dimensionLock) {
if (format.containsKey(MEDIA_FORMAT_KEY_STRIDE)) {
stride = format.getInteger(MEDIA_FORMAT_KEY_STRIDE);
}
if (format.containsKey(MEDIA_FORMAT_KEY_SLICE_HEIGHT)) {
sliceHeight = format.getInteger(MEDIA_FORMAT_KEY_SLICE_HEIGHT);
}
Logging.d(TAG, "Frame stride and slice height: " + stride + " x " + sliceHeight);
stride = Math.max(width, stride);
sliceHeight = Math.max(height, sliceHeight);
}
}
private void releaseCodecOnOutputThread() {
outputThreadChecker.checkIsOnValidThread();
Logging.d(TAG, "Releasing MediaCodec on output thread");
try {
codec.stop();
} catch (Exception e) {
Logging.e(TAG, "Media decoder stop failed", e);
}
try {
codec.release();
} catch (Exception e) {
Logging.e(TAG, "Media decoder release failed", e);
// Propagate exceptions caught during release back to the main thread.
shutdownException = e;
}
Logging.d(TAG, "Release on output thread done");
}
private void stopOnOutputThread(Exception e) {
outputThreadChecker.checkIsOnValidThread();
running = false;
shutdownException = e;
}
private boolean isSupportedColorFormat(int colorFormat) {
for (int supported : MediaCodecUtils.DECODER_COLOR_FORMATS) {
if (supported == colorFormat) {
return true;
}
}
return false;
}
// Visible for testing.
protected SurfaceTextureHelper createSurfaceTextureHelper() {
return SurfaceTextureHelper.create("decoder-texture-thread", sharedContext);
}
// Visible for testing.
// TODO(sakal): Remove once Robolectric commit fa991a0 has been rolled to WebRTC.
protected void releaseSurface() {
surface.release();
}
// Visible for testing.
protected VideoFrame.I420Buffer allocateI420Buffer(int width, int height) {
return JavaI420Buffer.allocate(width, height);
}
// Visible for testing.
protected void copyPlane(
ByteBuffer src, int srcStride, ByteBuffer dst, int dstStride, int width, int height) {
YuvHelper.copyPlane(src, srcStride, dst, dstStride, width, height);
}
}