Let the user pick a container and codecs independently, and remux without re-encoding

OutputFormat was a closed enum of twelve (container, videoCodec, audioCodec)
triples, defended on the grounds that a closed set was what made routing
decidable. Two things it could not express: changing the container while copying
the streams, and choosing codecs per track.

OutputSpec replaces it as the vocabulary; OutputFormat stays as presets over it.
Decidability moves to ContainerCapabilities, which is explicit and unit-tested
rather than implicit in whichever combinations somebody enumerated.

The matrix is indexed by (container, codec, trackType, mode), not one boolean.
"Can MP4 carry AV1" and "can this app encode AV1" have different answers, and
copy is where the difference shows: a single flag would refuse a legitimate
remux or promise an encode neither engine can deliver.

COPY is a codec value rather than a flag, so every exhaustive `when` in the
codebase had to say what it does about copying. CopyPlanner resolves it before
anything else reads the request, and inherits ConcatPlanner's rule that an
unproven match is never a copy — a needless re-encode costs time, a wrong stream
copy costs a file that will not play.

Container now drives -f, the extension and the SAF MIME type, so Matroska
without video is .mka and MP4 without video is .m4a without a preset for each.
FLAC was declared as Container.MKV with a .flac extension, inert only while
nothing read the container; it now has its own. Six containers added: MOV, MKV
audio, MPEG-TS, AVI, FLV and WMV/ASF.

Routing asks the plan, never the request. COPY belongs to none of the capability
sets, so testing the request directly sends every remux to FFmpeg on the first
check — and nothing notices, because -c copy produces a correct file, just on
the CPU. The router also learns what Media3 can *carry* as opposed to encode:
its MP4 muxer takes AAC, Opus, Vorbis and PCM but neither MP3 nor FLAC.

MediaProbe now separates "no video track" from "could not parse" and reports the
source container, which MediaExtractor cannot supply at all. FFprobe runs on
every pick for that reason, not as a fallback.

The Advanced picker shows the whole matrix and lets an impossible combination be
selected on purpose, then explains it and offers alternatives. Convert is what
blocks the job. ConversionWorker validates too, so a stale queued spec fails with
the reason rather than being coerced into something else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 07:07:52 -05:00
co-authored by Claude Opus 5
parent 00c422f317
commit 2e0cf2f5d6
35 changed files with 2824 additions and 263 deletions
@@ -100,7 +100,7 @@ class RealMediaBenchmark {
val hwOut = File(context.cacheDir, "bench_hw.mp4").apply { delete() }
val engine = Media3Engine(context)
val hwMs = try {
runCatching { timed { engine.transcode(uri, hwOut, OutputFormat.MP4_H265) } }
runCatching { timed { engine.transcode(uri, hwOut, ConversionRequest(OutputFormat.MP4_H265.spec)) } }
.onFailure { Log.w(TAG, "BENCH hardware: UNSUPPORTED (${it.message})") }
.getOrNull()
} finally {
@@ -112,7 +112,7 @@ class RealMediaBenchmark {
val swMs = timed {
FFmpegEngine().run(
request = ConversionRequest(
format = OutputFormat.MP4_H264,
spec = OutputFormat.MP4_H264.spec,
quality = QualityTier.BEST,
),
inputPath = input.absolutePath,
@@ -153,7 +153,7 @@ class RealMediaBenchmark {
val probe = MediaProbe.probe(context, Uri.fromFile(input))
val decision = ConversionRouter.route(
ConversionRequest(format = OutputFormat.MP4_H265, probe = probe),
ConversionRequest(spec = OutputFormat.MP4_H265.spec, probe = probe),
AndroidDeviceCodecs.get(),
)
Log.i(TAG, "BENCH av1 probe: codec=${probe.videoCodec} duration=${probe.durationMs}ms")
@@ -162,7 +162,7 @@ class RealMediaBenchmark {
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, OutputFormat.MP4_H265) }
timed { engine.transcode(Uri.fromFile(input), out, ConversionRequest(OutputFormat.MP4_H265.spec)) }
} finally {
engine.close()
}
@@ -16,6 +16,7 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.OutputFormat
import java.io.File
import java.util.concurrent.Executors
@@ -62,7 +63,7 @@ class Media3EngineTest {
engine.transcode(
input = Uri.fromFile(input),
output = output,
format = OutputFormat.MP4_H265,
request = ConversionRequest(OutputFormat.MP4_H265.spec),
) { percent -> seen += percent }
assertTrue("export produced no file", output.exists())
@@ -95,7 +96,7 @@ class Media3EngineTest {
val audio = File(context.cacheDir, "out_audio.m4a")
audio.delete()
try {
engine.transcode(Uri.fromFile(input), audio, OutputFormat.M4A_AAC)
engine.transcode(Uri.fromFile(input), audio, ConversionRequest(OutputFormat.M4A_AAC.spec))
assertTrue("export produced no file", audio.exists() && audio.length() > 0)
val tracks = trackMimeTypesOf(audio)
@@ -121,7 +122,7 @@ class Media3EngineTest {
val wav = File(context.cacheDir, "out_audio.wav")
wav.delete()
try {
engine.transcode(Uri.fromFile(input), wav, OutputFormat.WAV)
engine.transcode(Uri.fromFile(input), wav, ConversionRequest(OutputFormat.WAV.spec))
assertTrue("export produced no file", wav.exists() && wav.length() > 0)
assertEquals("RIFF", String(wav.readBytes().copyOfRange(0, 4), Charsets.US_ASCII))
@@ -141,7 +142,7 @@ class Media3EngineTest {
val ogg = File(context.cacheDir, "out_audio.opus")
ogg.delete()
try {
engine.transcode(Uri.fromFile(input), ogg, OutputFormat.OPUS)
engine.transcode(Uri.fromFile(input), ogg, ConversionRequest(OutputFormat.OPUS.spec))
assertTrue("export produced no file", ogg.exists() && ogg.length() > 0)
assertEquals("OggS", String(ogg.readBytes().copyOfRange(0, 4), Charsets.US_ASCII))
@@ -197,7 +198,7 @@ class Media3EngineTest {
val failure = runCatching {
runBlocking {
withTimeout(30_000) {
engine.transcode(Uri.fromFile(input), impossible, OutputFormat.MP4_H265) {}
engine.transcode(Uri.fromFile(input), impossible, ConversionRequest(OutputFormat.MP4_H265.spec)) {}
}
}
}.exceptionOrNull()
@@ -0,0 +1,277 @@
package org.libremediaconverter.convert
import android.media.MediaExtractor
import android.media.MediaFormat
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 kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.InputKind
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.work.ConversionWorker
import java.io.File
/**
* Remuxing end to end, on a device.
*
* ## Why these assert the engine, not just the file
*
* A remux routed to FFmpeg produces a perfectly correct file — `-c copy` moves the same samples
* into the same container. So an output-only assertion passes whether the hardware transmux path
* ran or never executed at all, and `COPY` belongs to none of the router's capability sets, which
* makes "silently always FFmpeg" the most likely way for this feature to regress.
*
* Timing is not a usable proxy either: it is flaky on emulators and cannot distinguish an FFmpeg
* stream copy from a Media3 transmux, since both are fast. `ConversionWorker` already reports
* `KEY_ENGINE_USED`, so these assert that.
*
* ## Fixtures
*
* Every committed asset was ISO MP4 before this feature, so a remux had no second container to
* move between. The new ones are generated rather than sourced, and committed rather than built at
* test time, following the convention `HardwareFallbackTest` documents — a regression test that
* silently skips is worse than no test. Recipes, run against FFmpeg 8.1.2:
*
* ```
* # sample_h264.mkv — the existing fixture's streams in Matroska. A pure remux, so the two files
* # hold byte-identical samples and differ only in the container, which is exactly the axis under
* # test. No encoder is involved, so the recipe reproduces anywhere.
* ffmpeg -i sample_h264.mp4 -c copy -f matroska sample_h264.mkv
*
* # sample_aac.m4a — the same fixture's audio track alone, for AUDIO_ONLY probing and MKA output.
* ffmpeg -i sample_h264.mp4 -vn -c:a copy -f mp4 sample_aac.m4a
*
* # sample_vp9.webm — the one fixture that must be encoded, since no committed source is VP9.
* ffmpeg -i sample_h264.mp4 -c:v libvpx-vp9 -crf 40 -b:v 0 -deadline realtime -cpu-used 8 \
* -c:a libopus -b:a 64k -f webm sample_vp9.webm
*
* # sample_still.png — a single frame, to reach the probe's IMAGE branch.
* ffmpeg -i sample_h264.mp4 -frames:v 1 -f image2 sample_still.png
* ```
*/
@UnstableApi
@RunWith(AndroidJUnit4::class)
class RemuxTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val staged = mutableListOf<File>()
@Before
fun setUp() {
WorkManager.getInstance(context).cancelAllWork()
}
@After
fun tearDown() {
staged.forEach { it.delete() }
WorkManager.getInstance(context).cancelAllWork()
}
private fun asset(name: String): File =
File(context.cacheDir, name).also { file ->
file.delete()
staged += file
InstrumentationRegistry.getInstrumentation().context.assets
.open(name)
.use { asset -> file.outputStream().use { asset.copyTo(it) } }
}
// --- probing ------------------------------------------------------------
@Test
fun probeIdentifiesTheSourceContainerOfEachFixture() {
assertEquals(Container.MP4, MediaProbe.probe(context, Uri.fromFile(asset("sample_h264.mp4"))).container)
assertEquals(Container.MKV, MediaProbe.probe(context, Uri.fromFile(asset("sample_h264.mkv"))).container)
assertEquals(Container.WEBM, MediaProbe.probe(context, Uri.fromFile(asset("sample_vp9.webm"))).container)
}
/**
* The distinction the old probe could not make.
*
* It reported `hasVideo = true, videoCodec = UNPARSEABLE` for anything the extractor refused,
* so an audio file, a picture and a corrupt file were indistinguishable — and the source-info
* card cannot describe any of them honestly until they are separate.
*/
@Test
fun probeDistinguishesAudioFromImagesFromRubbish() {
val audio = MediaProbe.probe(context, Uri.fromFile(asset("sample_aac.m4a")))
assertEquals(InputKind.AUDIO_ONLY, audio.kind)
assertEquals(false, audio.hasVideo)
assertNotNull("audio codec should still be identified", audio.audioCodec)
val image = MediaProbe.probe(context, Uri.fromFile(asset("sample_still.png")))
assertEquals(InputKind.IMAGE, image.kind)
val rubbish = File(context.cacheDir, "not_media.bin").also {
staged += it
it.writeBytes(ByteArray(4096) { i -> (i % 251).toByte() })
}
val unreadable = MediaProbe.probe(context, Uri.fromFile(rubbish))
assertEquals(InputKind.UNPARSEABLE, unreadable.kind)
}
// --- the hardware remux path --------------------------------------------
/**
* MKV in, MP4 out, nothing re-encoded — and it must stay on Media3.
*
* Media3 cannot *write* Matroska but reads it perfectly well, which is what makes this
* direction a hardware remux. If this reports FFMPEG, the transmux path is dead.
*/
@Test
fun mkvToMp4RemuxesOnHardware() {
val input = asset("sample_h264.mkv")
val result = runConversion(
input,
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY),
)
assertEquals(Engine.MEDIA3.name, result.engineUsed)
assertEquals("h264 must survive a copy untouched", "video/avc", videoMimeOf(result.output))
assertTrue("output has no duration", durationMsOf(result.output) > 0)
}
/** The reverse direction: Media3 has no Matroska muxer, so this is FFmpeg's. */
@Test
fun mp4ToMkvRemuxesOnFFmpeg() {
val input = asset("sample_h264.mp4")
val result = runConversion(
input,
OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY),
)
assertEquals(Engine.FFMPEG.name, result.engineUsed)
assertEquals(
"expected an EBML header",
listOf(0x1A, 0x45, 0xDF, 0xA3),
result.output.readBytes().take(4).map { it.toInt() and 0xFF },
)
}
@Test
fun webmToMkvKeepsVp9WithoutReencoding() {
val input = asset("sample_vp9.webm")
val result = runConversion(
input,
OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY),
)
assertTrue(result.output.length() > 0)
assertEquals("vp9 must survive a copy", "video/x-vnd.on2.vp9", videoMimeOf(result.output))
}
@Test
fun audioOnlySourceRemuxesIntoMka() {
val input = asset("sample_aac.m4a")
val spec = OutputSpec(Container.MKV, VideoCodec.NONE, AudioCodec.COPY)
val result = runConversion(input, spec)
assertEquals("mka", spec.extension)
assertTrue(result.output.length() > 0)
val tracks = trackMimesOf(result.output)
assertTrue("expected audio only, got $tracks", tracks.none { it.startsWith("video/") })
}
@Test
fun mp4ToMpegTsAndAviProduceTheirOwnContainers() {
val input = asset("sample_h264.mp4")
val ts = runConversion(input, OutputSpec(Container.MPEG_TS, VideoCodec.COPY, AudioCodec.COPY))
assertEquals(Engine.FFMPEG.name, ts.engineUsed)
// Every MPEG-TS packet starts with the 0x47 sync byte.
assertEquals(0x47, ts.output.readBytes().first().toInt() and 0xFF)
val avi = runConversion(input, OutputSpec(Container.AVI, VideoCodec.COPY, AudioCodec.COPY))
assertEquals("RIFF", String(avi.output.readBytes().copyOfRange(0, 4), Charsets.US_ASCII))
}
// --- plumbing -----------------------------------------------------------
private class Result(val output: File, val engineUsed: String)
/**
* Runs one conversion through the real worker.
*
* Deliberately through `ConversionWorker` rather than an engine directly: the routing decision
* is the thing under test, and only the worker makes it.
*/
private fun runConversion(input: File, spec: OutputSpec): Result = runBlocking {
val manager = WorkManager.getInstance(context)
val request = ConversionWorker.request(
inputUri = Uri.fromFile(input),
displayName = input.name,
sizeBytes = input.length(),
spec = spec,
)
manager.enqueue(request).result.get()
val terminal = withTimeout(TIMEOUT_MS) {
manager.getWorkInfoByIdFlow(request.id).first { info ->
info != null && info.state.isFinished
}
}
assertEquals(
"conversion did not succeed: " +
terminal?.outputData?.getString(ConversionWorker.KEY_ERROR),
WorkInfo.State.SUCCEEDED,
terminal?.state,
)
val path = requireNotNull(terminal?.outputData?.getString(ConversionWorker.KEY_OUTPUT_PATH))
val output = File(path).also { staged += it }
assertTrue("output is empty", output.length() > 0)
Result(
output = output,
engineUsed = terminal?.outputData
?.getString(ConversionWorker.KEY_ENGINE_USED).orEmpty(),
)
}
private fun trackMimesOf(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()
}
}
private fun videoMimeOf(file: File): String? =
trackMimesOf(file).firstOrNull { it.startsWith("video/") }
private fun durationMsOf(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()
}
}
private companion object {
const val TIMEOUT_MS = 180_000L
}
}
@@ -25,7 +25,7 @@ object FakeFailures {
override suspend fun transcode(
input: Uri,
output: File,
format: OutputFormat,
request: ConversionRequest,
onProgress: (Int) -> Unit,
) {
called = true
@@ -66,7 +66,7 @@ class ForcedFailureTest {
inputUri = Uri.fromFile(input),
displayName = SAMPLE,
sizeBytes = input.length(),
format = format,
spec = format.spec,
quality = QualityTier.FAST,
)
@@ -70,7 +70,7 @@ class HardwareFallbackTest {
inputUri = Uri.fromFile(input),
displayName = SAMPLE,
sizeBytes = input.length(),
format = OutputFormat.MP4_H265,
spec = OutputFormat.MP4_H265.spec,
// Fast deliberately: this is the tier the router sends to Media3, so it is
// the tier where the fallback has to rescue the conversion.
quality = QualityTier.FAST,
@@ -53,7 +53,7 @@ class FFmpegEngineTest {
val out = outputFor("out_${format.name.lowercase()}.${format.extension}")
runBlocking {
engine.run(
request = ConversionRequest(format = format, quality = quality),
request = ConversionRequest(spec = format.spec, quality = quality),
inputPath = input.absolutePath,
output = out,
durationMs = 3_000,
@@ -155,7 +155,7 @@ class FFmpegEngineTest {
val failure = runCatching {
runBlocking {
engine.run(
request = ConversionRequest(format = OutputFormat.MP4_H264),
request = ConversionRequest(spec = OutputFormat.MP4_H264.spec),
inputPath = "/does/not/exist.mp4",
output = out,
durationMs = 1_000,
@@ -10,9 +10,12 @@ import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.codec.AndroidDeviceCodecs
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.model.QualityTier
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
@@ -78,7 +81,7 @@ class ConversionWorkerTest {
inputUri = Uri.fromFile(input),
displayName = "worker_sample.mp4",
sizeBytes = input.length(),
format = OutputFormat.MP4_H264,
spec = OutputFormat.MP4_H264.spec,
)
workManager.enqueue(request).result.get()
@@ -114,7 +117,7 @@ class ConversionWorkerTest {
inputUri = Uri.fromFile(input),
displayName = "worker_sample.mp4",
sizeBytes = input.length(),
format = OutputFormat.MP3,
spec = OutputFormat.MP3.spec,
)
workManager.enqueue(request).result.get()
@@ -152,7 +155,7 @@ class ConversionWorkerTest {
inputUri = Uri.fromFile(input),
displayName = "worker_sample.mp4",
sizeBytes = input.length(),
format = OutputFormat.MP4_H265,
spec = OutputFormat.MP4_H265.spec,
quality = QualityTier.FAST,
)
workManager.enqueue(request).result.get()
@@ -181,7 +184,7 @@ class ConversionWorkerTest {
inputUri = Uri.fromFile(input),
displayName = "worker_sample.mp4",
sizeBytes = input.length(),
format = OutputFormat.MP4_H264,
spec = OutputFormat.MP4_H264.spec,
quality = QualityTier.BEST,
)
workManager.enqueue(request).result.get()
@@ -199,9 +202,50 @@ class ConversionWorkerTest {
@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))
assertEquals(
"clip_converted.mp3",
ConversionWorker.outputNameFor("clip.mp4", OutputFormat.MP3.spec),
)
assertEquals(
"clip_converted.gif",
ConversionWorker.outputNameFor("clip.mov", OutputFormat.GIF.spec),
)
assertEquals(
"clip_converted.mkv",
ConversionWorker.outputNameFor("clip", OutputFormat.MKV_H264.spec),
)
}
/**
* The extension follows the container and whether a video track survives.
*
* It used to be a literal on each preset, so the only names reachable were the ones somebody
* had enumerated. Matroska without video is `.mka` and MP4 without video is `.m4a` — neither
* had a preset before, and both are now one tap away in the Advanced picker.
*/
@Test
fun outputNameDistinguishesAudioOnlyVariantsOfAContainer() {
assertEquals(
"clip_converted.mka",
ConversionWorker.outputNameFor(
"clip.mkv",
OutputSpec(Container.MKV, VideoCodec.NONE, AudioCodec.FLAC),
),
)
assertEquals(
"clip_converted.m4a",
ConversionWorker.outputNameFor(
"clip.mp4",
OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.AAC),
),
)
assertEquals(
"clip_converted.mkv",
ConversionWorker.outputNameFor(
"clip.mp4",
OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY),
),
)
}
private companion object {