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