Files
LibreMediaConverter/app/src/androidTest/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegEngineTest.kt
T
JMR-devandClaude Opus 5 0c352cdcfb Route conversions between hardware and software engines
Adds the second engine and the rules that choose between them, turning a
video transcoder into a converter.

The router encodes capability boundaries, not preferences. Media3 handles
what it genuinely can and FFmpeg takes the rest:

  MKV, and any container Media3 cannot mux
  MP3, which Android cannot encode at any API level -- a platform gap
    rather than a Media3 limitation
  GIF and PNG frame sequences, which have no Media3 muxer
  VP9 and AV1 targets: Transformer.setVideoMimeType accepts only
    H.263/H.264/H.265/MP4V, so the WebM muxer has no encoder behind it
  inputs with no platform decoder, since Transformer ignores ExoPlayer's
    bundled software decoders and the dav1d extension does not rescue it
  the Best quality tier, because CRF and two-pass come from x264/x265 and
    no Android hardware encoder exposes either

Rule order matters and is deliberate: specific reasons are checked before
general ones because the reason is shown to the user. "Android has no
encoder for this format" is actionable for MP3; "this container needs
FFmpeg" is not. A test caught the original ordering getting this backwards.

Hardware support is vendor-declared and, per the platform's own docs,
"cannot be tested for correctness", so the static rules are backed by a
dynamic fallback: a Media3 export that fails is retried on FFmpeg rather
than surfaced as a failed conversion.

Joining files chooses between a stream copy and a re-encode by inspecting
the inputs. The concat demuxer requires matching codec, resolution and
timebase, and does not reliably fail when they differ -- it can emit a file
whose later segments are garbled. Unknown properties count as a mismatch,
because two nulls are not evidence of agreement.

The UI surfaces the routing decision rather than hiding it, so a slow job
explains itself, and offers a per-job engine override.

Navigation is adaptive: a bottom bar on phones, a side rail on wider
screens. Not cosmetic -- from targetSdk 37 Android ignores screenOrientation
and resizableActivity on displays at least 600dp wide, with no opt-out, so
the app is resized whether or not it is ready.

62 unit tests cover the routing matrix, the FFmpeg argument builder and the
concat planner on the JVM, against fabricated device profiles so branches
like "this device cannot encode HEVC" are reachable without that hardware.
Device-level tests for the FFmpeg formats are written but not yet run; the
emulator in this environment will not stay up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:39:19 -05:00

168 lines
5.8 KiB
Kotlin

package dev.jasonmross.mediaconverter.ffmpeg
import android.media.MediaExtractor
import android.media.MediaFormat
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import dev.jasonmross.mediaconverter.model.ConversionRequest
import dev.jasonmross.mediaconverter.model.OutputFormat
import dev.jasonmross.mediaconverter.model.QualityTier
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* Exercises the bundled FFmpeg on-device.
*
* These are the formats the app exists for that Media3 structurally cannot produce, so
* they are also the ones with no other coverage. Each test asserts against the produced
* file rather than the exit code, because FFmpeg will happily report success after
* writing something unplayable if the arguments are wrong.
*/
@RunWith(AndroidJUnit4::class)
class FFmpegEngineTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val engine = FFmpegEngine()
private lateinit var input: File
private val outputs = mutableListOf<File>()
@Before
fun setUp() {
input = File(context.cacheDir, "ffmpeg_sample.mp4")
InstrumentationRegistry.getInstrumentation().context.assets
.open("sample_h264.mp4")
.use { asset -> input.outputStream().use { asset.copyTo(it) } }
}
@After
fun tearDown() {
input.delete()
outputs.forEach { it.delete() }
}
private fun outputFor(name: String) =
File(context.cacheDir, name).also { it.delete(); outputs += it }
private fun convert(format: OutputFormat, quality: QualityTier = QualityTier.BEST): File {
val out = outputFor("out_${format.name.lowercase()}.${format.extension}")
runBlocking {
engine.run(
request = ConversionRequest(format = format, quality = quality),
inputPath = input.absolutePath,
output = out,
durationMs = 3_000,
)
}
return out
}
private fun trackMimes(file: File): List<String> {
val extractor = MediaExtractor()
return try {
extractor.setDataSource(file.absolutePath)
(0 until extractor.trackCount).map {
extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME).orEmpty()
}
} finally {
extractor.release()
}
}
// --- the formats that justify bundling FFmpeg at all -------------------
@Test
fun encodesMp3WhichAndroidCannotDoAtAnyApiLevel() {
val out = convert(OutputFormat.MP3)
assertTrue("no MP3 produced", out.exists() && out.length() > 0)
assertTrue(
"expected an mpeg audio track, got ${trackMimes(out)}",
trackMimes(out).any { it.contains("mp") && it.startsWith("audio/") },
)
}
@Test
fun encodesGifWithAGeneratedPalette() {
val out = convert(OutputFormat.GIF)
assertTrue("no GIF produced", out.exists() && out.length() > 0)
// GIF87a / GIF89a magic. Proves a real GIF rather than a mislabelled file.
val magic = out.inputStream().use { String(it.readNBytes(6)) }
assertTrue("not a GIF: $magic", magic.startsWith("GIF"))
}
@Test
fun encodesMatroskaWhichMedia3CannotMux() {
val out = convert(OutputFormat.MKV_H264)
assertTrue("no MKV produced", out.exists() && out.length() > 0)
// EBML magic, the Matroska container header.
val magic = out.inputStream().use { it.readNBytes(4) }
assertEquals(0x1A.toByte(), magic[0])
assertEquals(0x45.toByte(), magic[1])
assertEquals(0xDF.toByte(), magic[2])
assertEquals(0xA3.toByte(), magic[3])
}
@Test
fun encodesFlacLosslessAudio() {
val out = convert(OutputFormat.FLAC)
assertTrue("no FLAC produced", out.exists() && out.length() > 0)
}
@Test
fun encodesWav() {
val out = convert(OutputFormat.WAV)
assertTrue("no WAV produced", out.exists() && out.length() > 0)
val magic = out.inputStream().use { String(it.readNBytes(4)) }
assertEquals("RIFF", magic)
}
@Test
fun encodesOpus() {
val out = convert(OutputFormat.OPUS)
assertTrue("no Opus produced", out.exists() && out.length() > 0)
}
// --- the quality tier the GPL licence was taken for --------------------
@Test
fun bestQualityProducesAPlayableH264File() {
val out = convert(OutputFormat.MP4_H264, QualityTier.BEST)
assertTrue("no MP4 produced", out.exists() && out.length() > 0)
assertTrue(
"expected an AVC track, got ${trackMimes(out)}",
trackMimes(out).any { it == MediaFormat.MIMETYPE_VIDEO_AVC },
)
}
@Test
fun bestQualityProducesAPlayableH265File() {
val out = convert(OutputFormat.MP4_H265, QualityTier.BEST)
assertTrue("no MP4 produced", out.exists() && out.length() > 0)
assertTrue(
"expected an HEVC track, got ${trackMimes(out)}",
trackMimes(out).any { it == MediaFormat.MIMETYPE_VIDEO_HEVC },
)
}
@Test
fun failureSurfacesAsAnExceptionRatherThanASilentEmptyFile() {
val out = outputFor("nope.mp4")
val failure = runCatching {
runBlocking {
engine.run(
request = ConversionRequest(format = OutputFormat.MP4_H264),
inputPath = "/does/not/exist.mp4",
output = out,
durationMs = 1_000,
)
}
}.exceptionOrNull()
assertTrue("expected an FFmpegException, got $failure", failure is FFmpegEngine.FFmpegException)
}
}