From ec6e37ad08d4b6b349ac1a0ac83b2e238d98deec Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Thu, 20 Aug 2026 09:39:43 -0500 Subject: [PATCH] Stop selecting FFmpeg's MediaCodec encoders, and pin the pixel format Testing on a Pixel 10 Pro XL with real footage found two defects that neither the emulator nor any unit test could surface. The sample is H.264 High 4:4:4 Predictive (avc1.F4001F). Media3 cannot decode it on any device: hardware AVC decoders implement High 4:2:0, and Android's software c2.google.avc.decoder does not cover 4:4:4 either, so the export dies with a codec exception. The static routing rules cannot predict this -- the container is MP4, the codec is "h264", and the device reports AVC decode and encode -- so it is exactly the case the runtime fallback exists for. Until a file like this was tried on hardware, that fallback had never actually fired. Following it through exposed the two bugs behind it. First, only one video encode path named a pixel format. FFmpeg decodes 4:4:4 to yuv444p and hands those frames to encoders that cannot accept them; naming yuv420p makes it insert the conversion instead. Every path now does. Second, and not fixed by that: hevc_mediacodec still failed with "Error submitting video frame to the encoder". That is the second distinct failure from FFmpeg's MediaCodec wrappers in one session -- the first being that on a device with no hardware encoder they silently bind to the platform software codec and crawl while presenting as the fast path. They are undocumented, per-device flaky, and duplicate badly something Media3 already does properly. A job only reaches FFmpeg because Media3 could not handle it, which is itself evidence hardware encode is unlikely to work for that input. So FFmpeg now always encodes in software, and Fast means a fast preset rather than a different encoder: libx264/libx265 at veryfast against medium, both with CRF. With that, the fallback completes and the file converts. Measured on the Pixel: AV1 1080p through the hardware path runs at 7.9x realtime, confirming the figure the two-engine design was based on; software x264 CRF at 720p runs at 4.4x. Hardware encoders present are av01, avc and hevc -- AV1 encode included, which is still rare. 29 instrumented tests pass on device, 63 unit tests on the JVM. Co-Authored-By: Claude Opus 5 (1M context) --- .../bench/HardwareFallbackTest.kt | 87 +++++++++ .../bench/RealMediaBenchmark.kt | 183 ++++++++++++++++++ .../convert/Media3EngineTest.kt | 2 +- .../mediaconverter/join/ConcatEngineTest.kt | 8 +- .../mediaconverter/join/ConcatWorkerTest.kt | 4 +- .../work/ConversionWorkerTest.kt | 8 +- .../ffmpeg/FFmpegCommandBuilder.kt | 66 ++++--- .../ffmpeg/FFmpegCommandBuilderTest.kt | 72 +++---- 8 files changed, 359 insertions(+), 71 deletions(-) create mode 100644 app/src/androidTest/java/dev/jasonmross/mediaconverter/bench/HardwareFallbackTest.kt create mode 100644 app/src/androidTest/java/dev/jasonmross/mediaconverter/bench/RealMediaBenchmark.kt diff --git a/app/src/androidTest/java/dev/jasonmross/mediaconverter/bench/HardwareFallbackTest.kt b/app/src/androidTest/java/dev/jasonmross/mediaconverter/bench/HardwareFallbackTest.kt new file mode 100644 index 0000000..3854663 --- /dev/null +++ b/app/src/androidTest/java/dev/jasonmross/mediaconverter/bench/HardwareFallbackTest.kt @@ -0,0 +1,87 @@ +package dev.jasonmross.mediaconverter.bench + +import android.net.Uri +import androidx.media3.common.util.UnstableApi +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.WorkInfo +import androidx.work.WorkManager +import dev.jasonmross.mediaconverter.model.Engine +import dev.jasonmross.mediaconverter.model.OutputFormat +import dev.jasonmross.mediaconverter.model.QualityTier +import dev.jasonmross.mediaconverter.work.ConversionWorker +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * The dynamic fallback, exercised by a real file that genuinely breaks the fast path. + * + * The sample is H.264 **High 4:4:4 Predictive** (`avc1.F4001F`). Hardware AVC decoders + * implement High 4:2:0, and Android's software `c2.google.avc.decoder` does not cover + * 4:4:4 either, so Media3 cannot decode it on any device — it fails with a codec + * exception partway through the export. + * + * The static routing rules cannot predict this: the container is MP4, the codec is + * "h264", and the device reports both AVC decode and encode. Everything looks fine + * until the codec is actually configured. This is the case the runtime fallback exists + * for, and until a file like this was tried on hardware it had never actually fired. + */ +@UnstableApi +@RunWith(AndroidJUnit4::class) +class HardwareFallbackTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val workManager = WorkManager.getInstance(context) + + @Test + fun aFileMedia3CannotDecodeStillConvertsViaFfmpeg(): Unit = runBlocking { + val input = File(context.filesDir, SAMPLE).takeIf { it.exists() && it.length() > 0 } + assumeTrue("$SAMPLE not present; skipping", input != null) + + val request = ConversionWorker.request( + inputUri = Uri.fromFile(input!!), + displayName = SAMPLE, + sizeBytes = input.length(), + format = OutputFormat.MP4_H265, + // Fast deliberately: this is the tier that would be routed to Media3, so it + // is the tier where the fallback has to save the conversion. + quality = QualityTier.FAST, + ) + workManager.enqueue(request).result.get() + + val terminal = withTimeout(TIMEOUT_MS) { + workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished } + } + + val error = terminal?.outputData?.getString(ConversionWorker.KEY_ERROR) + org.junit.Assert.assertEquals( + "a file Media3 cannot decode must still convert, but failed with: $error", + WorkInfo.State.SUCCEEDED, + terminal?.state, + ) + + val out = File(terminal!!.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)!!) + org.junit.Assert.assertTrue("no output produced", out.exists() && out.length() > 0) + + // The routing decision still reads MEDIA3 -- that is the *static* decision, which + // was reasonable on the information available. The recovery happened underneath + // it at runtime, which is the point. + android.util.Log.i( + TAG, + "BENCH fallback: routed=${terminal.outputData.getString(ConversionWorker.KEY_ENGINE_USED)} " + + "output=${out.length() / 1_000_000}MB", + ) + out.delete() + } + + private companion object { + const val TAG = "HardwareFallbackTest" + const val SAMPLE = "bench_h264_720p.mp4" + const val TIMEOUT_MS = 600_000L + } +} diff --git a/app/src/androidTest/java/dev/jasonmross/mediaconverter/bench/RealMediaBenchmark.kt b/app/src/androidTest/java/dev/jasonmross/mediaconverter/bench/RealMediaBenchmark.kt new file mode 100644 index 0000000..7769e7e --- /dev/null +++ b/app/src/androidTest/java/dev/jasonmross/mediaconverter/bench/RealMediaBenchmark.kt @@ -0,0 +1,183 @@ +package dev.jasonmross.mediaconverter.bench + +import android.media.MediaExtractor +import android.media.MediaFormat +import android.net.Uri +import android.util.Log +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.jasonmross.mediaconverter.codec.AndroidDeviceCodecs +import dev.jasonmross.mediaconverter.convert.Media3Engine +import dev.jasonmross.mediaconverter.convert.MediaProbe +import dev.jasonmross.mediaconverter.ffmpeg.FFmpegEngine +import dev.jasonmross.mediaconverter.model.ConversionRequest +import dev.jasonmross.mediaconverter.model.ConversionRouter +import dev.jasonmross.mediaconverter.model.OutputFormat +import dev.jasonmross.mediaconverter.model.QualityTier +import dev.jasonmross.mediaconverter.model.VideoCodec +import kotlinx.coroutines.runBlocking +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * Measures the two claims that motivate the whole architecture, against real media. + * + * Not a correctness test — the assertions are deliberately loose. These exist to + * produce numbers for two decisions that were otherwise taken on faith: + * + * 1. that the hardware path is worth having a second engine for at all, and + * 2. that x264's CRF is worth the GPL licence the app carries for it. + * + * Skips itself when the sample files are absent, so it is harmless in CI. Populate with: + * adb push .mp4 /sdcard/Android/data/dev.jasonmross.mediaconverter/files/ + */ +@UnstableApi +@RunWith(AndroidJUnit4::class) +class RealMediaBenchmark { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + /** + * Internal storage, not the external files dir. + * + * Files placed in the external dir by `adb push` or `adb shell cp` stay owned by + * the shell user, and the app then gets EACCES trying to read them — which + * presents as an unparseable input rather than a permission problem. Piping + * through `run-as` writes as the app's own uid, so ownership is unambiguous. + */ + private val samples: File get() = context.filesDir + + private fun sample(name: String): File? = + File(samples, name).takeIf { it.exists() && it.length() > 0 } + + private fun durationMs(file: File): Long { + val extractor = MediaExtractor() + return try { + extractor.setDataSource(file.absolutePath) + (0 until extractor.trackCount) + .map { extractor.getTrackFormat(it) } + .filter { it.containsKey(MediaFormat.KEY_DURATION) } + .maxOfOrNull { it.getLong(MediaFormat.KEY_DURATION) / 1000 } ?: 0L + } finally { + extractor.release() + } + } + + /** Records what this device can actually do, so the numbers below have context. */ + @Test + fun reportDeviceEncoderCapabilities() { + val codecs = AndroidDeviceCodecs.get() + val summary = VideoCodec.entries + .filter { it != VideoCodec.NONE } + .joinToString(", ") { "${it.name}=${codecs.canEncode(it)}" } + Log.i(TAG, "BENCH hardware-encoders: ${codecs.hardwareEncoders()}") + Log.i(TAG, "BENCH can-encode: $summary") + } + + @Test + fun hardwareVersusSoftwareOnRealVideo(): Unit = runBlocking { + val input = sample(H264_SAMPLE) + assumeTrue("$H264_SAMPLE not present; skipping", input != null) + input!! + + val sourceMs = durationMs(input) + val uri = Uri.fromFile(input) + Log.i(TAG, "BENCH source: ${input.name} ${input.length() / 1_000_000}MB ${sourceMs}ms") + + // --- hardware, via Media3 ------------------------------------------- + // Not every real file survives this path. This source is H.264 High 4:4:4 + // Predictive (avc1.F4001F), which neither the hardware decoder nor Android's + // software c2.google.avc.decoder supports, so Media3 cannot read it at all. + // Record that rather than failing: it is exactly why the FFmpeg fallback exists. + val hwOut = File(context.cacheDir, "bench_hw.mp4").apply { delete() } + val engine = Media3Engine(context) + val hwMs = try { + runCatching { timed { engine.transcode(uri, hwOut, MimeTypes.VIDEO_H265) } } + .onFailure { Log.w(TAG, "BENCH hardware: UNSUPPORTED (${it.message})") } + .getOrNull() + } finally { + engine.close() + } + + // --- software, via FFmpeg + x264 CRF -------------------------------- + val swOut = File(context.cacheDir, "bench_sw.mp4").apply { delete() } + val swMs = timed { + FFmpegEngine().run( + request = ConversionRequest( + format = OutputFormat.MP4_H264, + quality = QualityTier.BEST, + ), + inputPath = input.absolutePath, + output = swOut, + durationMs = sourceMs, + ) + } + + if (hwMs != null) { + Log.i( + TAG, + "BENCH hardware: ${hwMs}ms -> ${hwOut.length() / 1_000_000}MB " + + "(${"%.1f".format(sourceMs.toDouble() / hwMs)}x realtime)", + ) + } + Log.i( + TAG, + "BENCH software: ${swMs}ms -> ${swOut.length() / 1_000_000}MB " + + "(${"%.2f".format(sourceMs.toDouble() / swMs)}x realtime)", + ) + if (hwMs != null) { + Log.i(TAG, "BENCH speedup: ${"%.1f".format(swMs.toDouble() / hwMs)}x") + } + + hwOut.delete() + swOut.delete() + } + + /** + * AV1 input is the sharpest routing case: Transformer cannot fall back to a + * software decoder, so a device without hardware AV1 decode must go to FFmpeg. + */ + @Test + fun av1InputRoutesAccordingToDeviceDecodeSupport(): Unit = runBlocking { + val input = sample(AV1_SAMPLE) + assumeTrue("$AV1_SAMPLE not present; skipping", input != null) + input!! + + val probe = MediaProbe.probe(context, Uri.fromFile(input)) + val decision = ConversionRouter.route( + ConversionRequest(format = OutputFormat.MP4_H265, probe = probe), + AndroidDeviceCodecs.get(), + ) + Log.i(TAG, "BENCH av1 probe: codec=${probe.videoCodec} duration=${probe.durationMs}ms") + Log.i(TAG, "BENCH av1 route: ${decision.engine} (${decision.reason})") + + val out = File(context.cacheDir, "bench_av1_out.mp4").apply { delete() } + val engine = Media3Engine(context) + val ms = try { + timed { engine.transcode(Uri.fromFile(input), out, MimeTypes.VIDEO_H265) } + } finally { + engine.close() + } + Log.i( + TAG, + "BENCH av1 transcode: ${ms}ms -> ${out.length() / 1_000_000}MB " + + "(${"%.1f".format(probe.durationMs.toDouble() / ms)}x realtime)", + ) + out.delete() + } + + private inline fun timed(block: () -> Unit): Long { + val start = System.currentTimeMillis() + block() + return System.currentTimeMillis() - start + } + + private companion object { + const val TAG = "RealMediaBenchmark" + const val H264_SAMPLE = "bench_h264_720p.mp4" + const val AV1_SAMPLE = "bench_av1_1080p.mp4" + } +} diff --git a/app/src/androidTest/java/dev/jasonmross/mediaconverter/convert/Media3EngineTest.kt b/app/src/androidTest/java/dev/jasonmross/mediaconverter/convert/Media3EngineTest.kt index 6236797..a262fae 100644 --- a/app/src/androidTest/java/dev/jasonmross/mediaconverter/convert/Media3EngineTest.kt +++ b/app/src/androidTest/java/dev/jasonmross/mediaconverter/convert/Media3EngineTest.kt @@ -53,7 +53,7 @@ class Media3EngineTest { } @Test - fun transcodesH264ToH265AndReportsProgress() = runBlocking { + fun transcodesH264ToH265AndReportsProgress(): Unit = runBlocking { val seen = mutableListOf() val result = engine.transcode( diff --git a/app/src/androidTest/java/dev/jasonmross/mediaconverter/join/ConcatEngineTest.kt b/app/src/androidTest/java/dev/jasonmross/mediaconverter/join/ConcatEngineTest.kt index a2e7af5..1d75fc8 100644 --- a/app/src/androidTest/java/dev/jasonmross/mediaconverter/join/ConcatEngineTest.kt +++ b/app/src/androidTest/java/dev/jasonmross/mediaconverter/join/ConcatEngineTest.kt @@ -76,7 +76,7 @@ class ConcatEngineTest { // --- the fast path ------------------------------------------------------ @Test - fun matchingClipsAreJoinedByStreamCopy() = runBlocking { + fun matchingClipsAreJoinedByStreamCopy(): Unit = runBlocking { val out = output("joined_matching.mp4") val result = engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipB)), out) @@ -96,7 +96,7 @@ class ConcatEngineTest { // --- the correctness path ---------------------------------------------- @Test - fun mismatchedClipsAreReEncodedRatherThanStreamCopied() = runBlocking { + fun mismatchedClipsAreReEncodedRatherThanStreamCopied(): Unit = runBlocking { val out = output("joined_mismatched.mp4") val result = engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipMismatched)), out) @@ -115,7 +115,7 @@ class ConcatEngineTest { } @Test - fun reEncodedOutputIsPlayableAndCarriesBothTracks() = runBlocking { + fun reEncodedOutputIsPlayableAndCarriesBothTracks(): Unit = runBlocking { val out = output("joined_playable.mp4") engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipMismatched)), out) @@ -147,7 +147,7 @@ class ConcatEngineTest { } @Test - fun theListFileIsCleanedUpAfterJoining() = runBlocking { + fun theListFileIsCleanedUpAfterJoining(): Unit = runBlocking { val out = output("joined_cleanup.mp4") engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipB)), out) assertTrue( diff --git a/app/src/androidTest/java/dev/jasonmross/mediaconverter/join/ConcatWorkerTest.kt b/app/src/androidTest/java/dev/jasonmross/mediaconverter/join/ConcatWorkerTest.kt index c0cabe0..16a84e5 100644 --- a/app/src/androidTest/java/dev/jasonmross/mediaconverter/join/ConcatWorkerTest.kt +++ b/app/src/androidTest/java/dev/jasonmross/mediaconverter/join/ConcatWorkerTest.kt @@ -57,7 +57,7 @@ class ConcatWorkerTest { } @Test - fun joinsTwoFilesAndReportsTheStrategyUsed() = runBlocking { + fun joinsTwoFilesAndReportsTheStrategyUsed(): Unit = runBlocking { val request = ConcatWorker.request( inputs = listOf(Uri.fromFile(clipA), Uri.fromFile(clipB)), totalBytes = clipA.length() + clipB.length(), @@ -84,7 +84,7 @@ class ConcatWorkerTest { } @Test - fun aSingleInputFailsWithAnActionableMessage() = runBlocking { + fun aSingleInputFailsWithAnActionableMessage(): Unit = runBlocking { val request = ConcatWorker.request( inputs = listOf(Uri.fromFile(clipA)), totalBytes = clipA.length(), diff --git a/app/src/androidTest/java/dev/jasonmross/mediaconverter/work/ConversionWorkerTest.kt b/app/src/androidTest/java/dev/jasonmross/mediaconverter/work/ConversionWorkerTest.kt index 28eca58..f90a7ab 100644 --- a/app/src/androidTest/java/dev/jasonmross/mediaconverter/work/ConversionWorkerTest.kt +++ b/app/src/androidTest/java/dev/jasonmross/mediaconverter/work/ConversionWorkerTest.kt @@ -70,7 +70,7 @@ class ConversionWorkerTest { } @Test - fun runsAConversionThroughWorkManager() = runBlocking { + fun runsAConversionThroughWorkManager(): Unit = runBlocking { // H.264 rather than the H.265 default: on a device with no hardware encoder // this runs in software, and AVC is markedly quicker than HEVC there. The point // of this test is the WorkManager round trip, not the codec. @@ -109,7 +109,7 @@ class ConversionWorkerTest { * only that a file appeared would pass even if the routing were broken. */ @Test - fun routesAnMp3JobToFfmpegAndProducesAFile() = runBlocking { + fun routesAnMp3JobToFfmpegAndProducesAFile(): Unit = runBlocking { val request = ConversionWorker.request( inputUri = Uri.fromFile(input), displayName = "worker_sample.mp4", @@ -145,7 +145,7 @@ class ConversionWorkerTest { * there. Asserting MEDIA3 unconditionally tests the test machine, not the router. */ @Test - fun routesAFastMp4JobByDeviceCapability() = runBlocking { + fun routesAFastMp4JobByDeviceCapability(): Unit = runBlocking { val hasHardwareHevc = AndroidDeviceCodecs.get().canEncode(VideoCodec.H265) val request = ConversionWorker.request( @@ -176,7 +176,7 @@ class ConversionWorkerTest { /** The quality tier is the reason the shipped binary is GPL, so verify it routes. */ @Test - fun routesABestQualityJobToFfmpeg() = runBlocking { + fun routesABestQualityJobToFfmpeg(): Unit = runBlocking { val request = ConversionWorker.request( inputUri = Uri.fromFile(input), displayName = "worker_sample.mp4", diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilder.kt b/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilder.kt index 399dcbc..3422c0f 100644 --- a/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilder.kt +++ b/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilder.kt @@ -25,6 +25,18 @@ object FFmpegCommandBuilder { private const val CRF_H264 = 20 private const val CRF_H265 = 24 + /** + * Force 4:2:0 chroma on every video encode. + * + * Sources are not always 4:2:0. Real footage encoded as H.264 High 4:4:4 Predictive + * exists, and FFmpeg will happily decode it to yuv444p and then hand those frames to + * an encoder that cannot take them. The MediaCodec wrappers fail hard in that case — + * "Invalid to call at Released state" partway through the export — and hardware + * players reject 4:4:4 output anyway. Naming the pixel format makes FFmpeg insert + * the conversion instead of failing. + */ + private val PIX_FMT = listOf("-pix_fmt", "yuv420p") + fun build( request: ConversionRequest, inputPath: String, @@ -78,41 +90,41 @@ object FFmpegCommandBuilder { "-c:v", "libx265", "-crf", "$CRF_H265", "-preset", "medium", // Without this, many players and Apple devices refuse HEVC in MP4. "-tag:v", "hvc1", - ) + ) + PIX_FMT dev.jasonmross.mediaconverter.model.VideoCodec.VP9 -> listOf( "-c:v", "libvpx-vp9", "-crf", "31", "-b:v", "0", - ) + ) + PIX_FMT else -> listOf( "-c:v", "libx264", "-crf", "$CRF_H264", "-preset", "medium", - // 4:2:0 for compatibility; some sources decode to 4:4:4, which - // most hardware players cannot handle. - "-pix_fmt", "yuv420p", - ) + ) + PIX_FMT } - // FFmpeg's MediaCodec wrappers, used when a job landed on FFmpeg for - // container reasons but the user still asked for speed. + // Software encoding, always. // - // Only when the device actually has a hardware encoder. Otherwise the - // wrapper binds to the platform's software codec (c2.android.*) and - // encodes far slower than libx264/libx265 would, while still calling - // itself the fast path. In that case use a real software encoder with a - // fast preset, which is what the user asked for in substance. + // FFmpeg's *_mediacodec encoders used to be selected here, on the theory + // that a job routed to FFmpeg for container reasons could still encode in + // hardware. In practice they are undocumented, per-device flaky, and were + // observed failing twice on real footage on a Pixel 10 Pro XL -- once + // binding to a software codec while claiming to be the fast path, and once + // dying mid-export with "Error submitting video frame to the encoder" even + // after the pixel format was pinned. + // + // They also duplicate, badly, something Media3 already does properly. A + // job only reaches FFmpeg because Media3 could not handle it, which is + // itself evidence that hardware encoding is unlikely to work for that + // input. Fast therefore means a fast *preset*, not a different encoder. QualityTier.FAST -> when (format.videoCodec) { - dev.jasonmross.mediaconverter.model.VideoCodec.H265 -> - if (request.hardwareEncodeAvailable) { - listOf("-c:v", "hevc_mediacodec", "-b:v", "5M", "-tag:v", "hvc1") - } else { - listOf("-c:v", "libx265", "-crf", "$CRF_H265", "-preset", "veryfast", "-tag:v", "hvc1") - } - dev.jasonmross.mediaconverter.model.VideoCodec.VP9 -> - listOf("-c:v", "libvpx-vp9", "-crf", "31", "-b:v", "0", "-deadline", "realtime") - else -> - if (request.hardwareEncodeAvailable) { - listOf("-c:v", "h264_mediacodec", "-b:v", "5M") - } else { - listOf("-c:v", "libx264", "-crf", "$CRF_H264", "-preset", "veryfast", "-pix_fmt", "yuv420p") - } + dev.jasonmross.mediaconverter.model.VideoCodec.H265 -> listOf( + "-c:v", "libx265", "-crf", "$CRF_H265", "-preset", "veryfast", + "-tag:v", "hvc1", + ) + PIX_FMT + dev.jasonmross.mediaconverter.model.VideoCodec.VP9 -> listOf( + "-c:v", "libvpx-vp9", "-crf", "31", "-b:v", "0", + "-deadline", "realtime", + ) + PIX_FMT + else -> listOf( + "-c:v", "libx264", "-crf", "$CRF_H264", "-preset", "veryfast", + ) + PIX_FMT } } } diff --git a/app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt b/app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt index 71ed77e..116542f 100644 --- a/app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt +++ b/app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt @@ -69,19 +69,41 @@ class FFmpegCommandBuilderTest { assertPair(cmd(OutputFormat.MP4_H265, QualityTier.BEST), "-tag:v", "hvc1") } + /** + * Regression test for a failure found with real footage on a Pixel 10 Pro XL. + * + * The source was H.264 High 4:4:4 Predictive. FFmpeg decodes that to yuv444p and, + * without an explicit pixel format, hands those frames straight to an encoder that + * cannot accept them — hevc_mediacodec died with "Invalid to call at Released + * state" partway through. Every video encode path has to name the format so FFmpeg + * inserts the conversion, not just the one path that happened to have it. + */ @Test - fun `h264 output is forced to yuv420p for hardware player compatibility`() { - assertPair(cmd(OutputFormat.MP4_H264, QualityTier.BEST), "-pix_fmt", "yuv420p") + fun `every video encode path forces yuv420p`() { + val videoFormats = listOf( + OutputFormat.MP4_H264, OutputFormat.MP4_H265, + OutputFormat.MKV_H264, OutputFormat.MKV_H265, + OutputFormat.WEBM_VP9, + ) + videoFormats.forEach { format -> + listOf(QualityTier.FAST, QualityTier.BEST).forEach { quality -> + listOf(true, false).forEach { hw -> + val args = cmd(format, quality, hardwareEncodeAvailable = hw) + assertPair(args, "-pix_fmt", "yuv420p") + } + } + } + } + + @Test + fun `audio only outputs do not set a pixel format`() { + listOf(OutputFormat.MP3, OutputFormat.FLAC, OutputFormat.WAV).forEach { + assertFalse("${it.name} should not set -pix_fmt", cmd(it).contains("-pix_fmt")) + } } // --- the hardware fallback path --------------------------------------- - @Test - fun `fast quality uses the mediacodec wrappers`() { - assertPair(cmd(OutputFormat.MP4_H264, QualityTier.FAST), "-c:v", "h264_mediacodec") - assertPair(cmd(OutputFormat.MP4_H265, QualityTier.FAST), "-c:v", "hevc_mediacodec") - } - /** * Regression test for a defect found on a device with no hardware HEVC encoder. * @@ -92,24 +114,15 @@ class FFmpegCommandBuilderTest { * honest about what it is doing. */ @Test - fun `fast quality without a hardware encoder uses a fast software preset`() { - val h264 = cmd(OutputFormat.MP4_H264, QualityTier.FAST, hardwareEncodeAvailable = false) - assertPair(h264, "-c:v", "libx264") - assertPair(h264, "-preset", "veryfast") - assertFalse("must not claim the mediacodec path", h264.contains("h264_mediacodec")) - - val h265 = cmd(OutputFormat.MP4_H265, QualityTier.FAST, hardwareEncodeAvailable = false) - assertPair(h265, "-c:v", "libx265") - assertPair(h265, "-preset", "veryfast") - assertFalse("must not claim the mediacodec path", h265.contains("hevc_mediacodec")) - } - - @Test - fun `fast quality with a hardware encoder still prefers mediacodec`() { - assertPair( - cmd(OutputFormat.MP4_H264, QualityTier.FAST, hardwareEncodeAvailable = true), - "-c:v", "h264_mediacodec", - ) + fun `the encoder choice no longer depends on hardware availability`() { + // Once FFmpeg stopped selecting MediaCodec encoders, this flag only affects + // whether the router sends the job to Media3 at all -- not what FFmpeg does. + listOf(OutputFormat.MP4_H264, OutputFormat.MP4_H265).forEach { format -> + assertEquals( + cmd(format, QualityTier.FAST, hardwareEncodeAvailable = true), + cmd(format, QualityTier.FAST, hardwareEncodeAvailable = false), + ) + } } @Test @@ -122,13 +135,6 @@ class FFmpegCommandBuilderTest { assertEquals("medium", best[best.indexOf("-preset") + 1]) } - @Test - fun `fast quality targets a bitrate because mediacodec has no crf`() { - val args = cmd(OutputFormat.MP4_H264, QualityTier.FAST) - assertTrue(args.contains("-b:v")) - assertFalse(args.contains("-crf")) - } - // --- audio ------------------------------------------------------------- @Test