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)