From 2fcacd199cce039e5de381b26c390d2fdd9c934e Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Thu, 20 Aug 2026 08:21:37 -0500 Subject: [PATCH] Use a real software encoder when the device has none in hardware Running the device suite for the first time surfaced a defect the unit tests could not: FFmpeg's *_mediacodec wrappers do not fail on a device without a hardware encoder. They quietly bind to the platform's software codec (c2.android.hevc.encoder on the test emulator) and encode far slower than libx264 or libx265 would, while still presenting as the fast path. A three second 320x240 clip did not finish inside a three minute timeout. The Fast tier now checks whether a hardware encoder actually exists for the target codec, and falls back to libx264/libx265 on -preset veryfast when one does not. That is both quicker and honest about what it is doing, and the distinction between Fast and Best survives the fallback: veryfast against medium, rather than both collapsing to the same slow path. The routing itself was already correct. The capability probe rightly rejects c2.android.* as software-only, so the job was correctly sent to FFmpeg -- the test asserting Media3 was encoding an assumption about the machine it ran on. It now derives its expectation from the device, so it means the same thing on hardware with a real encoder and on an emulator without one. Verified on an API 37 emulator: 17 of 17 instrumented tests pass in 4.2s, against six minutes of timeouts before. 65 unit tests still pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../work/ConversionWorkerTest.kt | 28 +++++++++-- .../ffmpeg/FFmpegCommandBuilder.kt | 22 +++++++-- .../mediaconverter/model/OutputFormat.kt | 9 ++++ .../mediaconverter/work/ConversionWorker.kt | 11 ++++- .../ffmpeg/FFmpegCommandBuilderTest.kt | 47 ++++++++++++++++++- 5 files changed, 107 insertions(+), 10 deletions(-) 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 ee854c1..28eca58 100644 --- a/app/src/androidTest/java/dev/jasonmross/mediaconverter/work/ConversionWorkerTest.kt +++ b/app/src/androidTest/java/dev/jasonmross/mediaconverter/work/ConversionWorkerTest.kt @@ -8,9 +8,11 @@ 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.codec.AndroidDeviceCodecs import dev.jasonmross.mediaconverter.model.Engine import dev.jasonmross.mediaconverter.model.OutputFormat import dev.jasonmross.mediaconverter.model.QualityTier +import dev.jasonmross.mediaconverter.model.VideoCodec import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -69,10 +71,14 @@ class ConversionWorkerTest { @Test fun runsAConversionThroughWorkManager() = 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. val request = ConversionWorker.request( inputUri = Uri.fromFile(input), displayName = "worker_sample.mp4", sizeBytes = input.length(), + format = OutputFormat.MP4_H264, ) workManager.enqueue(request).result.get() @@ -130,8 +136,18 @@ class ConversionWorkerTest { assertTrue("wrong extension: ${output.name}", output.name.endsWith(".mp3")) } + /** + * A Fast MP4 job takes the hardware path *when the device has one*. + * + * The expectation is derived from the device rather than assumed. Emulators + * typically expose only `c2.android.*` software codecs, which the capability probe + * correctly rejects as non-hardware, so the same job legitimately routes to FFmpeg + * there. Asserting MEDIA3 unconditionally tests the test machine, not the router. + */ @Test - fun routesAFastMp4JobToMedia3() = runBlocking { + fun routesAFastMp4JobByDeviceCapability() = runBlocking { + val hasHardwareHevc = AndroidDeviceCodecs.get().canEncode(VideoCodec.H265) + val request = ConversionWorker.request( inputUri = Uri.fromFile(input), displayName = "worker_sample.mp4", @@ -145,9 +161,15 @@ class ConversionWorkerTest { workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished } } - assertEquals(WorkInfo.State.SUCCEEDED, terminal?.state) assertEquals( - Engine.MEDIA3.name, + "job did not succeed: ${terminal?.outputData?.getString(ConversionWorker.KEY_ERROR)}", + WorkInfo.State.SUCCEEDED, + terminal?.state, + ) + assertEquals( + if (hasHardwareHevc) "hardware HEVC present, expected the Media3 path" + else "no hardware HEVC encoder, expected the FFmpeg path", + if (hasHardwareHevc) Engine.MEDIA3.name else Engine.FFMPEG.name, terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED), ) } 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 3765b81..399dcbc 100644 --- a/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilder.kt +++ b/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilder.kt @@ -90,15 +90,29 @@ object FFmpegCommandBuilder { ) } - // FFmpeg's MediaCodec wrappers. Used when a job landed on FFmpeg for + // FFmpeg's MediaCodec wrappers, used when a job landed on FFmpeg for // container reasons but the user still asked for speed. + // + // 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. QualityTier.FAST -> when (format.videoCodec) { dev.jasonmross.mediaconverter.model.VideoCodec.H265 -> - listOf("-c:v", "hevc_mediacodec", "-b:v", "5M", "-tag:v", "hvc1") + 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") + listOf("-c:v", "libvpx-vp9", "-crf", "31", "-b:v", "0", "-deadline", "realtime") else -> - listOf("-c:v", "h264_mediacodec", "-b:v", "5M") + if (request.hardwareEncodeAvailable) { + listOf("-c:v", "h264_mediacodec", "-b:v", "5M") + } else { + listOf("-c:v", "libx264", "-crf", "$CRF_H264", "-preset", "veryfast", "-pix_fmt", "yuv420p") + } } } } diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/model/OutputFormat.kt b/app/src/main/java/dev/jasonmross/mediaconverter/model/OutputFormat.kt index e62ab36..ccebb3d 100644 --- a/app/src/main/java/dev/jasonmross/mediaconverter/model/OutputFormat.kt +++ b/app/src/main/java/dev/jasonmross/mediaconverter/model/OutputFormat.kt @@ -84,4 +84,13 @@ data class ConversionRequest( val quality: QualityTier = QualityTier.FAST, val enginePreference: EnginePreference = EnginePreference.AUTO, val probe: InputProbe = InputProbe(), + /** + * Whether this device has a real hardware encoder for the target codec. + * + * When it does not, FFmpeg's `*_mediacodec` wrappers still appear to work: they + * bind to the platform's *software* codec (`c2.android.*`) and encode at a crawl + * while presenting as the fast path. Knowing this lets the Fast tier choose a + * genuinely fast software preset instead of a mislabelled slow one. + */ + val hardwareEncodeAvailable: Boolean = true, ) diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionWorker.kt b/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionWorker.kt index c9375cb..4837d05 100644 --- a/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionWorker.kt +++ b/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionWorker.kt @@ -69,8 +69,15 @@ class ConversionWorker( setForeground(foregroundInfo(displayName, percent = 0, indeterminate = true)) val probe = MediaProbe.probe(applicationContext, inputUri) - val request = ConversionRequest(format, quality, preference, probe) - val decision = ConversionRouter.route(request, AndroidDeviceCodecs.get()) + val devices = AndroidDeviceCodecs.get() + val request = ConversionRequest( + format = format, + quality = quality, + enginePreference = preference, + probe = probe, + hardwareEncodeAvailable = devices.canEncode(format.videoCodec), + ) + val decision = ConversionRouter.route(request, devices) Log.i(TAG, "Routing $displayName -> ${format.name} via ${decision.engine} (${decision.reason})") val staged = publisher.createStagingFile(outputNameFor(displayName, format)) 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 067739e..71ed77e 100644 --- a/app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt +++ b/app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt @@ -13,8 +13,13 @@ class FFmpegCommandBuilderTest { private fun cmd( format: OutputFormat, quality: QualityTier = QualityTier.BEST, + hardwareEncodeAvailable: Boolean = true, ): List = FFmpegCommandBuilder.build( - ConversionRequest(format = format, quality = quality), + ConversionRequest( + format = format, + quality = quality, + hardwareEncodeAvailable = hardwareEncodeAvailable, + ), inputPath = "/cache/in.mp4", outputPath = "/cache/out.${format.extension}", ) @@ -77,6 +82,46 @@ class FFmpegCommandBuilderTest { 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. + * + * FFmpeg's *_mediacodec wrappers do not fail on such a device — they quietly bind + * to the platform software codec (c2.android.*) and encode far slower than + * libx264/libx265 would, while still presenting as the fast path. When no hardware + * encoder exists, a real software encoder on a fast preset is both quicker and + * 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", + ) + } + + @Test + fun `fast software preset is faster than the best quality preset`() { + // Both use libx264; the distinction between the tiers has to survive the + // fallback, otherwise Fast and Best become the same slow thing. + val fast = cmd(OutputFormat.MP4_H264, QualityTier.FAST, hardwareEncodeAvailable = false) + val best = cmd(OutputFormat.MP4_H264, QualityTier.BEST) + assertEquals("veryfast", fast[fast.indexOf("-preset") + 1]) + 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)