Media3 can only write MP4, so stop claiming otherwise

CI proved the WAV and Ogg exports this branch added cannot work, and the reason
generalises further than those two.

media3-muxer 1.11.0 ships WebmMuxer, OggMuxer, WavMuxer and AacMuxer, which is
why MEDIA3_CONTAINERS listed the matching containers. But all four throw
UnsupportedOperationException from addMetadataEntry, and
MuxerWrapper.addTrackFormat calls it for every metadata entry on the track
format. Any real recording carries at least a creation timestamp, so the export
dies partway through:

    Caused by: java.lang.UnsupportedOperationException
        at androidx.media3.muxer.OggMuxer.addMetadataEntry(OggMuxer.java:123)
        at androidx.media3.transformer.MuxerWrapper.addTrackFormat(MuxerWrapper.java:488)

They are standalone muxers, not Transformer-compatible ones. WAV fails a second
way before even reaching that: DefaultEncoderFactory has no PCM encoder, so
Transformer reports "No MIME type is supported by both encoder and muxer"
instead of passing raw samples through.

Both observed on an API 35 emulator in CI, not inferred. The tests that found
them were written on the assumption these containers worked.

So MEDIA3_CONTAINERS becomes {MP4}. That the set was wrong went unnoticed
because the engine ignored the container and wrote MP4 regardless — the set
being wrong and the engine being wrong cancelled out. WAV, Opus and raw AAC move
to FFmpeg, which already produces all three with instrumented coverage asserting
the produced files.

WEBM_VP9's routing reason changes from NO_PLATFORM_ENCODER to
CONTAINER_UNSUPPORTED. Both were always true; the container is the more
fundamental, since even given a VP9 encoder the file could not be written.

The audio-only regression guard this branch exists for passed on API 35: M4A
output now carries exactly one AAC track and no video.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 07:17:11 -05:00
co-authored by Claude Opus 5
parent 00c422f317
commit 92b7395ba9
5 changed files with 86 additions and 207 deletions
@@ -1,12 +1,9 @@
package org.libremediaconverter.convert
import androidx.media3.common.C
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ConversionRouter
@@ -14,9 +11,10 @@ import org.libremediaconverter.model.ConversionRouter
/**
* Guards the agreement between the router's container set and the muxers that back it.
*
* These two drifted once already, and the failure was invisible: [ConversionRouter] correctly
* claimed WebM, Ogg, WAV and AAC for Media3 while [Media3Engine] had no way to write any of them
* and produced MP4 regardless. Nothing failed — the file was simply the wrong container.
* These two drifted once already, and the failure was invisible in both directions: the router
* claimed WebM, Ogg, WAV and AAC-ADTS for Media3 — none of which Transformer can actually write —
* while [Media3Engine] ignored the container and produced MP4 for all of them. Two bugs that
* cancelled out, so nothing failed and the file was simply the wrong container.
*
* A JVM test rather than an instrumented one: constructing a factory touches no Android APIs, only
* `create()` does, so the mapping is checkable without a device.
@@ -46,53 +44,17 @@ class Media3MuxersTest {
}
}
@Test
fun `WebM advertises only the codecs its muxer accepts`() {
val factory = requireNotNull(Media3Muxers.factoryFor(Container.WEBM))
assertEquals(
listOf(MimeTypes.VIDEO_VP8, MimeTypes.VIDEO_VP9),
factory.getSupportedSampleMimeTypes(C.TRACK_TYPE_VIDEO),
)
assertEquals(
listOf(MimeTypes.AUDIO_OPUS, MimeTypes.AUDIO_VORBIS),
factory.getSupportedSampleMimeTypes(C.TRACK_TYPE_AUDIO),
)
}
/**
* The audio-only containers must not claim a video track.
* MP4 is the whole of it.
*
* Transformer reads these lists to decide what it may hand the muxer. Claiming video for a
* container that cannot hold it turns a routing mistake into a corrupt file rather than a
* clean failure.
* Pinned as a value rather than left implicit, because `media3-muxer` shipping a `WebmMuxer`,
* `OggMuxer`, `WavMuxer` and `AacMuxer` makes it look like there should be five. Those four
* throw `UnsupportedOperationException` from `addMetadataEntry`, which `MuxerWrapper` calls
* unconditionally — see [Media3Muxers]. If a future Media3 release fixes that, this test is
* where the decision to widen the set gets made deliberately.
*/
@Test
fun `audio-only containers advertise no video codecs`() {
listOf(Container.OGG, Container.WAV, Container.AAC_ADTS).forEach { container ->
val factory = requireNotNull(Media3Muxers.factoryFor(container))
assertTrue(
"$container claims video support",
factory.getSupportedSampleMimeTypes(C.TRACK_TYPE_VIDEO).isEmpty(),
)
assertTrue(
"$container claims no audio support",
factory.getSupportedSampleMimeTypes(C.TRACK_TYPE_AUDIO).isNotEmpty(),
)
}
}
@Test
fun `WAV carries PCM and AAC-ADTS carries AAC`() {
assertEquals(
listOf(MimeTypes.AUDIO_RAW),
requireNotNull(Media3Muxers.factoryFor(Container.WAV))
.getSupportedSampleMimeTypes(C.TRACK_TYPE_AUDIO),
)
assertEquals(
listOf(MimeTypes.AUDIO_AAC),
requireNotNull(Media3Muxers.factoryFor(Container.AAC_ADTS))
.getSupportedSampleMimeTypes(C.TRACK_TYPE_AUDIO),
)
fun `Media3 can write MP4 and nothing else`() {
assertEquals(setOf(Container.MP4), ConversionRouter.MEDIA3_CONTAINERS)
}
}
@@ -39,13 +39,16 @@ class ConversionRouterTest {
}
@Test
fun `webm vp9 routes to ffmpeg because media3 cannot encode vp9`() {
// Transformer.setVideoMimeType accepts only H.263/H.264/H.265/MP4V. The WebM
// muxer exists, but there is no VP9 *encoder* behind it, so producing WebM
// video is FFmpeg's job even though the container is nominally supported.
fun `webm vp9 routes to ffmpeg`() {
// Two independent reasons, either of which is sufficient: Transformer.setVideoMimeType
// accepts only H.263/H.264/H.265/MP4V, so there is no VP9 encoder — and Media3 cannot mux
// WebM at all, which it was previously credited with. The container check runs first and
// is the more fundamental of the two: even given a VP9 encoder, the file could not be
// written. This asserted NO_PLATFORM_ENCODER while the container was wrongly believed to
// be supported.
val d = route(OutputFormat.WEBM_VP9)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.NO_PLATFORM_ENCODER, d.reason)
assertEquals(Reason.CONTAINER_UNSUPPORTED, d.reason)
}
@Test
@@ -173,6 +176,31 @@ class ConversionRouterTest {
assertEquals(Reason.USER_FORCED_SOFTWARE, d.reason)
}
// --- containers Media3 cannot write -------------------------------------
/**
* WAV, Opus and raw AAC used to be claimed for Media3 and are not any more.
*
* `media3-muxer` ships a muxer for each, which is why they were listed — but none can be driven
* by Transformer, so the export dies at the muxer. They were never actually produced on the
* hardware path: the engine ignored the container and wrote MP4 into a file named `.wav`.
* FFmpeg produces all three, and `FFmpegEngineTest` asserts the produced files.
*/
@Test
fun `audio containers Media3 cannot mux route to FFmpeg`() {
listOf(OutputFormat.WAV, OutputFormat.OPUS).forEach { format ->
val d = route(format)
assertEquals("$format should need FFmpeg", Engine.FFMPEG, d.engine)
assertEquals(Reason.CONTAINER_UNSUPPORTED, d.reason)
}
}
/** M4A is the audio format that does stay on hardware, because its container is MP4. */
@Test
fun `m4a stays on hardware because MP4 is a container Media3 can write`() {
assertEquals(Engine.MEDIA3, route(OutputFormat.M4A_AAC).engine)
}
// --- exhaustiveness ----------------------------------------------------
@Test