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>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ 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 kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
@@ -87,6 +90,93 @@ class ConversionWorkerTest {
|
||||
assertTrue("output file empty", output.length() > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole point of the router, end to end.
|
||||
*
|
||||
* MP3 cannot be produced by Media3 on any Android version, so this asserts both
|
||||
* that the job completes and that it was actually carried out by FFmpeg. Checking
|
||||
* only that a file appeared would pass even if the routing were broken.
|
||||
*/
|
||||
@Test
|
||||
fun routesAnMp3JobToFfmpegAndProducesAFile() = runBlocking {
|
||||
val request = ConversionWorker.request(
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = "worker_sample.mp4",
|
||||
sizeBytes = input.length(),
|
||||
format = OutputFormat.MP3,
|
||||
)
|
||||
workManager.enqueue(request).result.get()
|
||||
|
||||
val terminal = withTimeout(TIMEOUT_MS) {
|
||||
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
"mp3 job did not succeed: ${terminal?.outputData?.getString(ConversionWorker.KEY_ERROR)}",
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
terminal?.state,
|
||||
)
|
||||
assertEquals(
|
||||
Engine.FFMPEG.name,
|
||||
terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
|
||||
)
|
||||
val output = File(terminal!!.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)!!)
|
||||
assertTrue("no mp3 written", output.exists() && output.length() > 0)
|
||||
assertTrue("wrong extension: ${output.name}", output.name.endsWith(".mp3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun routesAFastMp4JobToMedia3() = runBlocking {
|
||||
val request = ConversionWorker.request(
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = "worker_sample.mp4",
|
||||
sizeBytes = input.length(),
|
||||
format = OutputFormat.MP4_H265,
|
||||
quality = QualityTier.FAST,
|
||||
)
|
||||
workManager.enqueue(request).result.get()
|
||||
|
||||
val terminal = withTimeout(TIMEOUT_MS) {
|
||||
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
|
||||
}
|
||||
|
||||
assertEquals(WorkInfo.State.SUCCEEDED, terminal?.state)
|
||||
assertEquals(
|
||||
Engine.MEDIA3.name,
|
||||
terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
|
||||
)
|
||||
}
|
||||
|
||||
/** The quality tier is the reason the shipped binary is GPL, so verify it routes. */
|
||||
@Test
|
||||
fun routesABestQualityJobToFfmpeg() = runBlocking {
|
||||
val request = ConversionWorker.request(
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = "worker_sample.mp4",
|
||||
sizeBytes = input.length(),
|
||||
format = OutputFormat.MP4_H264,
|
||||
quality = QualityTier.BEST,
|
||||
)
|
||||
workManager.enqueue(request).result.get()
|
||||
|
||||
val terminal = withTimeout(TIMEOUT_MS) {
|
||||
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
|
||||
}
|
||||
|
||||
assertEquals(WorkInfo.State.SUCCEEDED, terminal?.state)
|
||||
assertEquals(
|
||||
Engine.FFMPEG.name,
|
||||
terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outputNameTakesTheExtensionOfTheChosenFormat() {
|
||||
assertEquals("clip_converted.mp3", ConversionWorker.outputNameFor("clip.mp4", OutputFormat.MP3))
|
||||
assertEquals("clip_converted.gif", ConversionWorker.outputNameFor("clip.mov", OutputFormat.GIF))
|
||||
assertEquals("clip_converted.mkv", ConversionWorker.outputNameFor("clip", OutputFormat.MKV_H264))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_MS = 180_000L
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user