diff --git a/app/build.gradle b/app/build.gradle index a06da742..c17965c6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,7 +10,7 @@ android { minSdkVersion 27 targetSdkVersion 31 versionCode 1 - versionName "1.29" + versionName "1.30" setProperty("archivesBaseName", applicationId + "-v" + versionName) } diff --git a/app/releases/com.facebook.encapp-v1.30-debug.apk b/app/releases/com.facebook.encapp-v1.30-debug.apk new file mode 100644 index 00000000..711e4e7c Binary files /dev/null and b/app/releases/com.facebook.encapp-v1.30-debug.apk differ diff --git a/app/src/main/java/com/facebook/encapp/AsyncBufferEncoder.java b/app/src/main/java/com/facebook/encapp/AsyncBufferEncoder.java new file mode 100644 index 00000000..cd7bc182 --- /dev/null +++ b/app/src/main/java/com/facebook/encapp/AsyncBufferEncoder.java @@ -0,0 +1,573 @@ +package com.facebook.encapp; + +import static com.facebook.encapp.utils.MediaCodecInfoHelper.mediaFormatComparison; + +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaFormat; +import android.os.Build; +import android.util.Log; +import android.util.Size; + +import androidx.annotation.NonNull; + +import com.facebook.encapp.proto.PixFmt; +import com.facebook.encapp.proto.Test; +import com.facebook.encapp.utils.FakeInputReader; +import com.facebook.encapp.utils.FileReader; +import com.facebook.encapp.utils.FrameInfo; +import com.facebook.encapp.utils.MediaCodecInfoHelper; +import com.facebook.encapp.utils.SizeUtils; +import com.facebook.encapp.utils.Statistics; +import com.facebook.encapp.utils.TestDefinitionHelper; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Dictionary; +import java.util.Locale; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * AsyncBufferEncoder uses MediaCodec's async callback API for buffer-based encoding. + * + * Unlike BufferEncoder which uses synchronous polling, this encoder: + * - Uses callbacks for output buffer handling (like SurfaceEncoder) + * - Uses a separate input feeder thread for realtime pacing + * - Tracks available input buffers in a queue + * + * This allows copying data to an encoder buffer while another is in the encoding pipeline, + * achieving better throughput while maintaining realtime pacing. + */ +public class AsyncBufferEncoder extends Encoder { + private static final String TAG = "encapp.async_buffer_encoder"; + + private Size mSourceResolution; + private int mFrameSizeBytes; + private boolean mUseImage = false; + + // Available input buffer indices + private final ConcurrentLinkedQueue mAvailableInputBuffers = new ConcurrentLinkedQueue<>(); + + // Synchronization for completion + private final Object mCompletionLock = new Object(); + private final AtomicBoolean mInputDone = new AtomicBoolean(false); + private final AtomicBoolean mOutputDone = new AtomicBoolean(false); + + // Input feeder thread + private InputFeederThread mInputFeeder; + + public AsyncBufferEncoder(Test test) { + super(test); + mStats = new Statistics("async buffer encoder", mTest); + } + + @Override + public String start() { + Log.d(TAG, "** AsyncBufferEncoder - " + mTest.getCommon().getDescription() + " **"); + + mTest = TestDefinitionHelper.updateBasicSettings(mTest); + if (mTest.hasRuntime()) + mRuntimeParams = mTest.getRuntime(); + if (mTest.getInput().hasRealtime()) + mRealtime = mTest.getInput().getRealtime(); + + mFrameRate = mTest.getConfigure().getFramerate(); + mWriteFile = !mTest.getConfigure().hasEncode() || mTest.getConfigure().getEncode(); + mSkipped = 0; + mFramesAdded = 0; + + mSourceResolution = SizeUtils.parseXString(mTest.getInput().getResolution()); + int width = mSourceResolution.getWidth(); + int height = mSourceResolution.getHeight(); + + PixFmt inputFmt = mTest.getInput().getPixFmt(); + mFrameSizeBytes = MediaCodecInfoHelper.frameSizeInBytes(inputFmt, width, height); + mRefFramesizeInBytes = mFrameSizeBytes; + + // Initialize input reader + String filepath = mTest.getInput().getFilepath(); + if (filepath.equals("fake_input")) { + mFakeInputReader = new FakeInputReader(); + if (!mFakeInputReader.openFile(filepath, inputFmt, width, height)) { + return "Could not initialize fake input"; + } + mIsFakeInput = true; + Log.d(TAG, "Using FakeInputReader for fake_input"); + } else { + mYuvReader = new FileReader(); + String checkedPath = checkFilePath(filepath); + if (!mYuvReader.openFile(checkedPath, inputFmt)) { + return "Could not open file: " + checkedPath; + } + Log.d(TAG, "Using FileReader for: " + checkedPath); + } + + MediaFormat mediaFormat; + + try { + // Setup codec + if (mTest.getConfigure().getMime().length() == 0) { + try { + mTest = MediaCodecInfoHelper.setCodecNameAndIdentifier(mTest); + } catch (Exception e) { + return e.getMessage(); + } + } + + Log.d(TAG, "Create codec by name: " + mTest.getConfigure().getCodec()); + mStats.pushTimestamp("encoder.create"); + mCodec = MediaCodec.createByCodecName(mTest.getConfigure().getCodec()); + mStats.pushTimestamp("encoder.create"); + + mediaFormat = TestDefinitionHelper.buildMediaFormat(mTest); + logMediaFormat(mediaFormat); + setConfigureParams(mTest, mediaFormat); + + // Determine if we should use Image API + int colorFormat = mediaFormat.getInteger(MediaFormat.KEY_COLOR_FORMAT); + mUseImage = (colorFormat == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible); + Log.d(TAG, "useImage: " + mUseImage + ", colorFormat=" + colorFormat); + + // Set async callback handler BEFORE configure + mCodec.setCallback(new AsyncEncoderCallbackHandler()); + + mStats.pushTimestamp("encoder.configure"); + mCodec.configure( + mediaFormat, + null /* surface */, + null /* crypto */, + MediaCodec.CONFIGURE_FLAG_ENCODE); + mStats.pushTimestamp("encoder.configure"); + + logMediaFormat(mCodec.getInputFormat()); + mStats.setEncoderMediaFormat(mCodec.getInputFormat()); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + mStats.setCodec(mCodec.getCanonicalName()); + } else { + mStats.setCodec(mCodec.getName()); + } + } catch (IOException iox) { + Log.e(TAG, "Failed to create codec: " + iox.getMessage()); + return "Failed to create codec"; + } catch (MediaCodec.CodecException cex) { + Log.e(TAG, "Configure failed: " + cex.getMessage()); + return "Failed to create codec"; + } + + // Setup timing + mReferenceFrameRate = mTest.getInput().getFramerate(); + mKeepInterval = mReferenceFrameRate / mFrameRate; + mRefFrameTime = calculateFrameTimingUsec(mReferenceFrameRate); + mFrameTimeUsec = calculateFrameTimingUsec(mFrameRate); + + // Create muxer + Log.d(TAG, "Create muxer"); + MediaFormat outputFormat = mCodec.getOutputFormat(); + mMuxerWrapper = createMuxerWrapper(mCodec, outputFormat); + + boolean isVP = mCodec.getCodecInfo().getName().toLowerCase(Locale.US).contains(".vp"); + if (isVP) { + mVideoTrack = mMuxerWrapper.addTrack(outputFormat); + mMuxerWrapper.start(); + } + + // Wait for synchronized start + synchronized (this) { + Log.d(TAG, "Wait for synchronized start"); + try { + mInitDone = true; + wait(WAIT_TIME_MS); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + + // Start encoding + try { + Log.d(TAG, "Start encoder (async mode)"); + mStats.pushTimestamp("encoder.start"); + mCodec.start(); + mStats.pushTimestamp("encoder.start"); + } catch (Exception ex) { + Log.e(TAG, "Start failed: " + ex.getMessage()); + return "Start encoding failed"; + } + + mStats.start(); + + // Start input feeder thread - handles realtime pacing + mInputFeeder = new InputFeederThread(); + mInputFeeder.start(); + + // Wait for encoding to complete + synchronized (mCompletionLock) { + while (!mOutputDone.get()) { + try { + mCompletionLock.wait(100); + + // Log progress periodically + if (mFramesAdded % 100 == 0 && mFramesAdded > 0) { + Log.d(TAG, mTest.getCommon().getId() + " - AsyncBufferEncoder: frames: " + mFramesAdded + + " inframes: " + mInFramesCount + + " outframes: " + mOutFramesCount + + " current_time: " + mCurrentTimeSec); + } + } catch (InterruptedException e) { + break; + } + } + } + + Log.d(TAG, "Encoding complete: " + mFramesAdded + " frames added, " + mOutFramesCount + " output"); + + // Stop input feeder + if (mInputFeeder != null) { + mInputFeeder.stopFeeding(); + try { + mInputFeeder.join(1000); + } catch (InterruptedException e) { + Log.w(TAG, "Interrupted waiting for InputFeeder"); + } + } + + // Stop DataWriter + if (mDataWriter != null) { + mDataWriter.stopWriter(); + try { + mDataWriter.join(1000); + } catch (InterruptedException e) { + Log.w(TAG, "Interrupted waiting for DataWriter"); + } + } + + mStats.stop(); + + // Cleanup + if (mCodec != null) { + try { + mCodec.stop(); + mCodec.release(); + } catch (IllegalStateException e) { + Log.e(TAG, "Error stopping codec: " + e.getMessage()); + } + } + + if (mMuxerWrapper != null) { + try { + mMuxerWrapper.release(); + } catch (IllegalStateException ise) { + Log.e(TAG, "Error releasing muxer: " + ise.getMessage()); + } + } + + if (mYuvReader != null) { + mYuvReader.closeFile(); + } + if (mFakeInputReader != null) { + mFakeInputReader.closeFile(); + } + + return ""; + } + + /** + * Input feeder thread - handles realtime pacing and buffer filling. + * Waits for available input buffers AND the right time to submit frames. + */ + private class InputFeederThread extends Thread { + private volatile boolean mStopRequested = false; + private int mCurrentLoop = 1; + + public void stopFeeding() { + mStopRequested = true; + interrupt(); + } + + @Override + public void run() { + Log.d(TAG, "InputFeeder started, realtime=" + mRealtime); + + while (!mStopRequested && !mInputDone.get()) { + // Check if we're done + if (doneReading(mTest, mYuvReader, mInFramesCount, mCurrentTimeSec, false)) { + sendEndOfStream(); + break; + } + + // Wait for an available input buffer + Integer bufferIndex = mAvailableInputBuffers.poll(); + if (bufferIndex == null) { + // No buffer available, wait a bit + try { + Thread.sleep(1); + } catch (InterruptedException e) { + if (mStopRequested) break; + } + continue; + } + + // Realtime pacing - wait until it's time for the next frame + if (mRealtime) { + sleepUntilNextFrame(); + } + + // Fill and queue the buffer + int size = fillAndQueueBuffer(bufferIndex); + + if (size <= 0 && size != -2) { + // End of file or error - handle looping + if (mIsFakeInput) { + mFakeInputReader.closeFile(); + mFakeInputReader.openFile(mTest.getInput().getFilepath(), + mTest.getInput().getPixFmt(), + mSourceResolution.getWidth(), mSourceResolution.getHeight()); + } else if (mYuvReader != null) { + mYuvReader.closeFile(); + mYuvReader.openFile(mTest.getInput().getFilepath(), mTest.getInput().getPixFmt()); + } + mCurrentLoop++; + Log.d(TAG, "*** Loop ended start " + mCurrentLoop + " ***"); + + if (doneReading(mTest, mYuvReader, mInFramesCount, mCurrentTimeSec, true)) { + sendEndOfStream(); + break; + } + + // Return buffer to queue for retry + mAvailableInputBuffers.add(bufferIndex); + } + } + + Log.d(TAG, "InputFeeder stopped"); + } + + private void sendEndOfStream() { + if (mInputDone.getAndSet(true)) { + return; // Already sent EOS + } + + // Get a buffer for EOS + Integer bufferIndex = mAvailableInputBuffers.poll(); + if (bufferIndex == null) { + // Wait for a buffer + while (!mStopRequested && bufferIndex == null) { + bufferIndex = mAvailableInputBuffers.poll(); + if (bufferIndex == null) { + try { + Thread.sleep(1); + } catch (InterruptedException e) { + return; + } + } + } + } + + if (bufferIndex != null) { + long pts = computePresentationTimeUs(mPts, mInFramesCount, mRefFrameTime); + try { + mCodec.queueInputBuffer(bufferIndex, 0, 0, pts, MediaCodec.BUFFER_FLAG_END_OF_STREAM); + Log.d(TAG, "Queued EOS at frame " + mInFramesCount); + } catch (IllegalStateException e) { + Log.e(TAG, "Error queueing EOS: " + e.getMessage()); + } + } + } + } + + /** + * Fill a buffer and queue it to the encoder. + * @return size of data queued, -1 on error, -2 on skip + */ + private int fillAndQueueBuffer(int index) { + int read = 0; + + try { + if (mUseImage) { + android.media.Image image = mCodec.getInputImage(index); + if (image != null) { + if (mIsFakeInput) { + read = mFakeInputReader.fillImage(image); + } else { + read = mYuvReader.fillImage(image); + } + } else { + Log.e(TAG, "Failed to get input image"); + return -1; + } + } else { + ByteBuffer buffer = mCodec.getInputBuffer(index); + if (buffer != null) { + buffer.clear(); + if (mIsFakeInput) { + read = mFakeInputReader.fillBuffer(buffer, mFrameSizeBytes); + } else { + read = mYuvReader.fillBuffer(buffer, mFrameSizeBytes); + } + } else { + Log.e(TAG, "Failed to get input buffer"); + return -1; + } + } + + if (read <= 0) { + return read; + } + + long pts = computePresentationTimeUs(mPts, mInFramesCount, mRefFrameTime); + mCurrentTimeSec = pts / 1000000.0f; + + // Runtime parameters + setRuntimeParameters(mInFramesCount); + + // Frame dropping + mDropNext = dropFrame(mInFramesCount); + mDropNext |= dropFromDynamicFramerate(mInFramesCount); + updateDynamicFramerate(mInFramesCount); + + if (mDropNext) { + mSkipped++; + mDropNext = false; + mInFramesCount++; + // Return buffer to queue + mAvailableInputBuffers.add(index); + return -2; + } + + // Start encoding measurement + mStats.startEncodingFrame(pts, mInFramesCount); + + // Queue the buffer + mCodec.queueInputBuffer(index, 0, read, pts, 0); + mFramesAdded++; + mInFramesCount++; + + return read; + + } catch (IllegalStateException e) { + Log.e(TAG, "Error filling/queueing buffer: " + e.getMessage()); + return -1; + } + } + + /** + * Async callback handler for the encoder. + */ + private class AsyncEncoderCallbackHandler extends MediaCodec.Callback { + private MediaFormat mCurrentOutputFormat = null; + private Dictionary mLatestFrameChanges = null; + + @Override + public void onInputBufferAvailable(@NonNull MediaCodec codec, int index) { + // Add buffer to available queue - InputFeeder will use it + if (!mInputDone.get()) { + mAvailableInputBuffers.add(index); + } + } + + @Override + public void onOutputBufferAvailable(@NonNull MediaCodec codec, int index, @NonNull MediaCodec.BufferInfo info) { + try { + if ((info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { + // Codec config buffer + MediaFormat oformat = codec.getOutputFormat(); + if (mWriteFile && mMuxerWrapper != null && mVideoTrack == -1) { + mVideoTrack = mMuxerWrapper.addTrack(oformat); + mMuxerWrapper.start(); + Log.d(TAG, "Muxer started from codec config"); + } + codec.releaseOutputBuffer(index, false); + + } else if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + // End of stream + Log.d(TAG, "Output EOS received"); + codec.releaseOutputBuffer(index, false); + mOutputDone.set(true); + synchronized (mCompletionLock) { + mCompletionLock.notifyAll(); + } + + } else { + // Regular frame - stop encoding measurement + FrameInfo frameInfo = mStats.stopEncodingFrame(info.presentationTimeUs, info.size, + (info.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0); + ++mOutFramesCount; + + if (mLatestFrameChanges != null) { + frameInfo.addInfo(mLatestFrameChanges); + mLatestFrameChanges = null; + } + + // Write to muxer + if (mMuxerWrapper != null && mVideoTrack != -1) { + ByteBuffer data = codec.getOutputBuffer(index); + if (data != null) { + mMuxerWrapper.writeSampleData(mVideoTrack, data, info); + } + } + codec.releaseOutputBuffer(index, false); + } + } catch (IllegalStateException e) { + Log.e(TAG, "onOutputBufferAvailable error: " + e.getMessage()); + mOutputDone.set(true); + synchronized (mCompletionLock) { + mCompletionLock.notifyAll(); + } + } + } + + @Override + public void onError(@NonNull MediaCodec codec, @NonNull MediaCodec.CodecException e) { + Log.e(TAG, "Codec error: " + e.getMessage()); + mInputDone.set(true); + mOutputDone.set(true); + synchronized (mCompletionLock) { + mCompletionLock.notifyAll(); + } + } + + @Override + public void onOutputFormatChanged(@NonNull MediaCodec codec, @NonNull MediaFormat format) { + Log.d(TAG, "Output format changed: " + format); + + if (mWriteFile && mMuxerWrapper != null && mVideoTrack == -1) { + mVideoTrack = mMuxerWrapper.addTrack(format); + mMuxerWrapper.start(); + Log.d(TAG, "Muxer started from format change"); + } + + if (Build.VERSION.SDK_INT >= 29 && mCurrentOutputFormat != null) { + mLatestFrameChanges = mediaFormatComparison(mCurrentOutputFormat, format); + } + mCurrentOutputFormat = format; + } + } + + @Override + public void writeToBuffer(@NonNull MediaCodec codec, int index, boolean encoder) { + // Not used - we handle input via InputFeederThread + } + + @Override + public void readFromBuffer(@NonNull MediaCodec codec, int index, boolean encoder, MediaCodec.BufferInfo info) { + // Not used - we handle output in the callback directly + } + + @Override + public void release() { + // Cleanup handled in start() method + } + + @Override + public void stopAllActivity() { + mInputDone.set(true); + mOutputDone.set(true); + if (mInputFeeder != null) { + mInputFeeder.stopFeeding(); + } + synchronized (mCompletionLock) { + mCompletionLock.notifyAll(); + } + } +} diff --git a/app/src/main/java/com/facebook/encapp/Encoder.java b/app/src/main/java/com/facebook/encapp/Encoder.java index 04c953e9..fc4a48b1 100644 --- a/app/src/main/java/com/facebook/encapp/Encoder.java +++ b/app/src/main/java/com/facebook/encapp/Encoder.java @@ -541,7 +541,6 @@ protected int queueInputBufferEncoder( read = fileReader.fillBuffer(byteBuffer, size); } } - Log.d(TAG, "Read: " + read); long ptsUsec = computePresentationTimeUs(mPts, frameCount, mRefFrameTime); mCurrentTimeSec = ptsUsec / 1000000.0f; // set any runtime parameters for this frame diff --git a/app/src/main/java/com/facebook/encapp/MainActivity.java b/app/src/main/java/com/facebook/encapp/MainActivity.java index ded732b6..34b10e59 100644 --- a/app/src/main/java/com/facebook/encapp/MainActivity.java +++ b/app/src/main/java/com/facebook/encapp/MainActivity.java @@ -814,7 +814,15 @@ private Thread PerformTest(Test test) { } else if (!surface && !deviceDecode && deviceEncode) { Log.d(TAG, "3. Simple buffer encode"); - coder = new BufferEncoder(test); + // Use BufferEncoder for tiled encoding (HEIC), otherwise use AsyncBufferEncoder + boolean useTiledEncoding = test.getConfigure().hasTileWidth() || test.getConfigure().hasTileHeight(); + if (useTiledEncoding) { + Log.d(TAG, "3a. Using BufferEncoder for tiled encoding"); + coder = new BufferEncoder(test); + } else { + Log.d(TAG, "3b. Using AsyncBufferEncoder for better throughput"); + coder = new AsyncBufferEncoder(test); + } } else if (!surface && deviceDecode && deviceEncode) { Log.d(TAG, "4. Buffer transcode"); coder = new BufferTranscoder(test); diff --git a/app/src/main/java/com/facebook/encapp/utils/codec/AvcCodecWriter.java b/app/src/main/java/com/facebook/encapp/utils/codec/AvcCodecWriter.java index d104f46e..a1afdac8 100644 --- a/app/src/main/java/com/facebook/encapp/utils/codec/AvcCodecWriter.java +++ b/app/src/main/java/com/facebook/encapp/utils/codec/AvcCodecWriter.java @@ -369,9 +369,6 @@ private byte[] convertToAVCC(byte[] buffer, boolean isHEVC) { return null; } - log(String.format("Converted Annex-B to AVCC: %d NAL units, %d bytes total", - nalCount, avccBuffer.length)); - return avccBuffer; } } diff --git a/app/src/main/java/com/facebook/encapp/utils/codec/HevcCodecWriter.java b/app/src/main/java/com/facebook/encapp/utils/codec/HevcCodecWriter.java index c4e3f1c1..575af829 100644 --- a/app/src/main/java/com/facebook/encapp/utils/codec/HevcCodecWriter.java +++ b/app/src/main/java/com/facebook/encapp/utils/codec/HevcCodecWriter.java @@ -127,46 +127,46 @@ public int[] extractDimensionsFromFrame(byte[] frameData) { if (nalStart >= frameData.length) { break; } - + int nalHeader = frameData[nalStart] & 0xFF; int nalType = (nalHeader >> 1) & 0x3F; // NAL type 33 = SPS (Sequence Parameter Set) if (nalType == 33 && nalLength >= 20) { log(String.format("Found HEVC SPS NAL (type 33) at offset %d, length=%d", offset, nalLength)); - + try { // Parse pic_width_in_luma_samples and pic_height_in_luma_samples from SPS // These are encoded using Exponential-Golomb (ue(v)) after the profile_tier_level - + // Skip: NAL header (2 bytes) + sps_video_parameter_set_id (4 bits) // + sps_max_sub_layers_minus1 (3 bits) + sps_temporal_id_nesting_flag (1 bit) // + profile_tier_level (variable) + sps_seq_parameter_set_id (ue(v)) - + // For simplicity, use a heuristic: search for pic_width/height after byte 20 // Real HEVC SPS has these values typically around bytes 15-40 - + // Read raw SPS bytes and look for the dimension pattern int searchStart = nalStart + 15; // Skip NAL header + profile int searchEnd = Math.min(nalStart + nalLength, nalStart + 50); - + // HEVC dimensions are typically stored as multiples of minimum coding block size // For common videos, they appear as recognizable 16-bit values for (int i = searchStart; i < searchEnd - 3; i++) { int val1 = ((frameData[i] & 0xFF) << 8) | (frameData[i+1] & 0xFF); int val2 = ((frameData[i+2] & 0xFF) << 8) | (frameData[i+3] & 0xFF); - + // Check if these look like video dimensions (reasonable range) if (val1 >= 128 && val1 <= 8192 && val2 >= 128 && val2 <= 8192) { // Additional validation: dimensions should be even and not too unusual - if (val1 % 2 == 0 && val2 % 2 == 0 && + if (val1 % 2 == 0 && val2 % 2 == 0 && (val1 * val2) >= (128 * 128) && (val1 * val2) <= (8192 * 8192)) { log(String.format("Extracted HEVC dimensions from SPS: %dx%d", val1, val2)); return new int[]{val1, val2}; } } } - + logError("Could not find valid dimensions in HEVC SPS (heuristic search failed)"); } catch (Exception e) { logError("Failed to parse HEVC SPS: " + e.getMessage()); @@ -494,9 +494,6 @@ private byte[] convertToAVCC(byte[] buffer, boolean isHEVC) { return null; } - log(String.format("Converted Annex-B to AVCC: %d NAL units, %d bytes total", - nalCount, avccBuffer.length)); - return avccBuffer; } } diff --git a/scripts/tests/system/test_encapp.py b/scripts/tests/system/test_encapp.py index e0f01de5..8b93894c 100755 --- a/scripts/tests/system/test_encapp.py +++ b/scripts/tests/system/test_encapp.py @@ -208,3 +208,44 @@ def test_buffer_encoding(tmp_path, setup_data): ) except subprocess.CalledProcessError as err: pytest.fail(err.stdout) + + +def test_buffer_transcoding(tmp_path, setup_data): + """Verify buffer transcoding (decode + re-encode) works on test device""" + try: + # Get codec list and lookup a sw h264 codec + result = subprocess.run( + [ + f"{PYTHON_ENV} {ENCAPP_SCRIPT_PATH} " + f"--serial {ANDROID_SERIAL} list --codec '.*h264.*' --sw --encoder" + ], + shell=True, + check=True, + text=True, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + ) + codec = result.stdout.strip() + assert len(codec) > 0, "No h264 encoder found" + + output_path = f"{tmp_path}/encapp_buffer_transcode_test/" + subprocess.run( + [ + f"{PYTHON_ENV} {ENCAPP_SCRIPT_PATH} " + f"--serial {ANDROID_SERIAL} run " + f"{TEST_SCRIPTS_DIR}/system_test_buffer_transcode.pbtxt " + f"--codec {codec} --local-workdir {output_path}" + ], + shell=True, + check=True, + text=True, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + ) + # Verify output file was created + output_files = os.listdir(output_path) if os.path.exists(output_path) else [] + mp4_files = [f for f in output_files if f.endswith('.mp4')] + assert len(mp4_files) > 0, f"No output MP4 files found in {output_path}" + + except subprocess.CalledProcessError as err: + pytest.fail(f"Buffer transcoding failed: {err.stdout}") diff --git a/scripts/tests/system/test_fake_input.py b/scripts/tests/system/test_fake_input.py new file mode 100644 index 00000000..f249cf62 --- /dev/null +++ b/scripts/tests/system/test_fake_input.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +""" +System tests for fake input encoding. + +These tests verify fake input buffer encoding - uses synthetic data generation +instead of file input for performance testing without filesystem overhead. + +Requires: +- ANDROID_SERIAL environment variable to be set +- encapp app installed on the device +""" + +import os +import subprocess +import pytest + +PYTHON_ENV = "python3" +MODULE_PATH = os.path.dirname(__file__) +ENCAPP_SCRIPTS_DIR = os.path.join(MODULE_PATH, os.pardir, os.pardir) +ENCAPP_SCRIPT_PATH = os.path.join(ENCAPP_SCRIPTS_DIR, "encapp.py") +TEST_SCRIPTS_DIR = os.path.join(MODULE_PATH, os.pardir, os.pardir, os.pardir, "tests") +ANDROID_SERIAL = os.getenv("ANDROID_SERIAL") +ENCAPP_ALWAYS_INSTALL = os.getenv("ENCAPP_ALWAYS_INSTALL", "True") in [ + "True", + "true", + "1", +] + +assert ANDROID_SERIAL is not None, "ANDROID_SERIAL environment variable must be defined" + + +def uninstall(): + """Uninstall encapp from the device.""" + try: + subprocess.run( + [ + f"{PYTHON_ENV} {ENCAPP_SCRIPT_PATH} " + f"--serial {ANDROID_SERIAL} uninstall" + ], + shell=True, + check=True, + text=True, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + ) + except subprocess.CalledProcessError as err: + pytest.fail(err.stdout) + + +def install(): + """Install encapp on the device.""" + try: + subprocess.run( + [ + f"{PYTHON_ENV} {ENCAPP_SCRIPT_PATH} " + f"--serial {ANDROID_SERIAL} install" + ], + shell=True, + check=True, + text=True, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + ) + except subprocess.CalledProcessError as err: + pytest.fail(err.stdout) + + +@pytest.fixture +def setup_encapp(): + """Setup fixture that installs encapp if needed.""" + if ENCAPP_ALWAYS_INSTALL: + uninstall() + install() + yield + + +def test_fake_input_buffer_encoding(tmp_path, setup_encapp): + """Verify fake input buffer encoding works without file input.""" + try: + # Get a software H.264 encoder + result = subprocess.run( + [ + f"{PYTHON_ENV} {ENCAPP_SCRIPT_PATH} " + f"--serial {ANDROID_SERIAL} list --codec '.*h264.*' --sw --encoder" + ], + shell=True, + check=True, + text=True, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + ) + codec = result.stdout.strip() + assert len(codec) > 0, "No software H.264 encoder found" + + output_path = f"{tmp_path}/encapp_fake_input_test/" + subprocess.run( + [ + f"{PYTHON_ENV} {ENCAPP_SCRIPT_PATH} " + f"--serial {ANDROID_SERIAL} run " + f"{TEST_SCRIPTS_DIR}/system_test_fake_input.pbtxt " + f"--codec {codec} --local-workdir {output_path}" + ], + shell=True, + check=True, + text=True, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + ) + # Verify output file was created + output_files = os.listdir(output_path) if os.path.exists(output_path) else [] + mp4_files = [f for f in output_files if f.endswith('.mp4')] + assert len(mp4_files) > 0, f"No output MP4 files found in {output_path}" + + except subprocess.CalledProcessError as err: + pytest.fail(f"Fake input encoding failed: {err.stdout}") + + +def test_fake_input_produces_valid_output(tmp_path, setup_encapp): + """Verify fake input produces a valid, non-empty output file.""" + try: + # Get a software H.264 encoder + result = subprocess.run( + [ + f"{PYTHON_ENV} {ENCAPP_SCRIPT_PATH} " + f"--serial {ANDROID_SERIAL} list --codec '.*h264.*' --sw --encoder" + ], + shell=True, + check=True, + text=True, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + ) + codec = result.stdout.strip() + assert len(codec) > 0, "No software H.264 encoder found" + + output_path = f"{tmp_path}/encapp_fake_input_validate/" + subprocess.run( + [ + f"{PYTHON_ENV} {ENCAPP_SCRIPT_PATH} " + f"--serial {ANDROID_SERIAL} run " + f"{TEST_SCRIPTS_DIR}/system_test_fake_input.pbtxt " + f"--codec {codec} --local-workdir {output_path}" + ], + shell=True, + check=True, + text=True, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + ) + + # Check output file size is reasonable (at least 1KB for 100 frames) + output_files = os.listdir(output_path) if os.path.exists(output_path) else [] + mp4_files = [f for f in output_files if f.endswith('.mp4')] + assert len(mp4_files) > 0, "No output MP4 files found" + + for mp4_file in mp4_files: + file_path = os.path.join(output_path, mp4_file) + file_size = os.path.getsize(file_path) + assert file_size > 1024, f"Output file {mp4_file} is too small ({file_size} bytes)" + + except subprocess.CalledProcessError as err: + pytest.fail(f"Fake input validation failed: {err.stdout}") diff --git a/tests/system_test_buffer_transcode.pbtxt b/tests/system_test_buffer_transcode.pbtxt new file mode 100644 index 00000000..2300c200 --- /dev/null +++ b/tests/system_test_buffer_transcode.pbtxt @@ -0,0 +1,17 @@ +# System test for buffer transcoding +# Decodes video file and re-encodes using buffer mode +test { + input { + filepath: "/tmp/akiyo_qcif.mp4" + device_decode: true + } + common { + id: "system_test_buffer_transcode" + description: "Verify buffer transcoding (decode + encode)" + } + configure { + bitrate: "200 kbps" + framerate: 30 + i_frame_interval: 1 + } +} diff --git a/tests/system_test_fake_input.pbtxt b/tests/system_test_fake_input.pbtxt new file mode 100644 index 00000000..87ddcfa5 --- /dev/null +++ b/tests/system_test_fake_input.pbtxt @@ -0,0 +1,20 @@ +# System test for fake input buffer encoding +# Uses synthetic data generation instead of file input +test { + input { + filepath: "fake_input" + resolution: "640x480" + framerate: 30 + pix_fmt: nv12 + playout_frames: 100 + } + common { + id: "system_test_fake_input" + description: "Verify fake input buffer encoding" + } + configure { + bitrate: "500 kbps" + framerate: 30 + i_frame_interval: 1 + } +}