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,128 +1,60 @@
package org.libremediaconverter.convert
import androidx.media3.common.C
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.muxer.AacMuxer
import androidx.media3.muxer.Muxer
import androidx.media3.muxer.MuxerException
import androidx.media3.muxer.OggMuxer
import androidx.media3.muxer.SeekableMuxerOutput
import androidx.media3.muxer.WavMuxer
import androidx.media3.muxer.WebmMuxer
import androidx.media3.transformer.DefaultMuxer
import com.google.common.collect.ImmutableList
import org.libremediaconverter.model.Container
import java.io.FileOutputStream
/**
* Muxer factories for the containers Media3 can write.
* Which containers Media3 can actually write, and why it is only one.
*
* `media3-muxer` ships `Mp4Muxer`, `WebmMuxer`, `OggMuxer`, `WavMuxer` and `AacMuxer`, but
* `media3-transformer` only wraps the MP4 ones as a [Muxer.Factory]. Everything else needs the
* three lines of glue below, which is why the app previously wrote MP4 no matter what container
* was asked for: [androidx.media3.transformer.Transformer.Builder] defaults to
* `DefaultMuxer.Factory`, and nothing ever overrode it.
* ## The four other muxers cannot be driven by Transformer
*
* ## The MIME lists are load-bearing
* `media3-muxer` 1.11.0 ships `WebmMuxer`, `OggMuxer`, `WavMuxer` and `AacMuxer` alongside the MP4
* ones, which reads like four more containers on the hardware path. It is not: **all four throw
* `UnsupportedOperationException` from `addMetadataEntry`**, and
* `MuxerWrapper.addTrackFormat` calls it for every metadata entry on the track format. Any real
* recording carries some — a creation timestamp is enough — so the export dies partway through:
*
* `Transformer` calls [Muxer.Factory.getSupportedSampleMimeTypes] to decide whether a track can be
* copied through or has to be re-encoded, and `Transformer.Builder.build()` validates the
* requested MIME types against it. A list that over-claims produces a file the muxer cannot
* actually write; one that under-claims forces a needless re-encode. The values here were read out
* of each muxer's own `isMimeTypeSupported` check rather than assumed.
* ```
* 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 even before
* that: `DefaultEncoderFactory` has no PCM encoder, so Transformer reports "No MIME type is
* supported by both encoder and muxer" rather than passing raw samples through.
*
* Both were observed on a CI API 35 emulator, not inferred — the tests that found them were written
* on the assumption these containers worked.
*
* So Media3 writes MP4 and nothing else. WebM, Ogg, WAV and raw AAC belong to FFmpeg, which already
* produces all of them and has instrumented coverage asserting the produced files.
*/
@UnstableApi
object Media3Muxers {
/**
* The factory for [container], or null when Media3 cannot mux it at all.
* The factory for [container], or null when Media3 cannot write it.
*
* A null here must agree with [org.libremediaconverter.model.ConversionRouter]'s container set
* — if the router sends Media3 a job this cannot mux, the conversion fails and falls back to
* FFmpeg, which is a slow way to discover a routing bug. `Media3MuxersTest` asserts they agree.
* A null here must agree with [org.libremediaconverter.model.ConversionRouter]'s container set;
* `Media3MuxersTest` asserts they do. They drifted once already, and expensively: the router
* claimed five containers while the engine silently wrote MP4 for all of them.
*/
fun factoryFor(container: Container): Muxer.Factory? = when (container) {
// Transformer's own default. Named explicitly so the MP4 path reads the same as the rest.
// Transformer's own default. Named explicitly so the engine states its container rather
// than inheriting one, which is how the MP4-for-everything bug went unnoticed.
Container.MP4 -> DefaultMuxer.Factory()
Container.WEBM -> WebmFactory
Container.OGG -> OggFactory
Container.WAV -> WavFactory
Container.AAC_ADTS -> AacFactory
// Matroska, MP3 and the image outputs have no Media3 muxer. FFmpeg owns them.
Container.WEBM,
Container.OGG,
Container.WAV,
Container.AAC_ADTS,
Container.MKV,
Container.MP3,
Container.GIF,
Container.IMAGE_SEQUENCE,
-> null
}
private object WebmFactory : Muxer.Factory {
override fun create(path: String): Muxer = wrapFailure(path) {
WebmMuxer.Builder(SeekableMuxerOutput.of(path)).build()
}
override fun getSupportedSampleMimeTypes(trackType: Int): ImmutableList<String> =
when (trackType) {
C.TRACK_TYPE_VIDEO -> ImmutableList.of(MimeTypes.VIDEO_VP8, MimeTypes.VIDEO_VP9)
C.TRACK_TYPE_AUDIO -> ImmutableList.of(MimeTypes.AUDIO_OPUS, MimeTypes.AUDIO_VORBIS)
else -> ImmutableList.of()
}
}
private object OggFactory : Muxer.Factory {
override fun create(path: String): Muxer = wrapFailure(path) {
OggMuxer.Builder(FileOutputStream(path).channel).build()
}
override fun getSupportedSampleMimeTypes(trackType: Int): ImmutableList<String> =
if (trackType == C.TRACK_TYPE_AUDIO) {
ImmutableList.of(MimeTypes.AUDIO_OPUS, MimeTypes.AUDIO_VORBIS)
} else {
ImmutableList.of()
}
}
private object WavFactory : Muxer.Factory {
override fun create(path: String): Muxer = wrapFailure(path) {
WavMuxer(SeekableMuxerOutput.of(path))
}
override fun getSupportedSampleMimeTypes(trackType: Int): ImmutableList<String> =
if (trackType == C.TRACK_TYPE_AUDIO) {
ImmutableList.of(MimeTypes.AUDIO_RAW)
} else {
ImmutableList.of()
}
}
private object AacFactory : Muxer.Factory {
override fun create(path: String): Muxer = wrapFailure(path) {
AacMuxer(FileOutputStream(path))
}
override fun getSupportedSampleMimeTypes(trackType: Int): ImmutableList<String> =
if (trackType == C.TRACK_TYPE_AUDIO) {
ImmutableList.of(MimeTypes.AUDIO_AAC)
} else {
ImmutableList.of()
}
}
/**
* Turns an I/O failure into a [MuxerException].
*
* Opening the output can throw `FileNotFoundException`, which is not what `Muxer.Factory`
* declares. Transformer's error handling only recognises `MuxerException`, so letting the raw
* IOException escape turns a bad output path into an unhandled crash instead of a reported
* export failure — the case `Media3EngineTest.anUnwritableOutputPathFailsInsteadOfHanging`
* exists to pin down.
*/
private inline fun wrapFailure(path: String, open: () -> Muxer): Muxer =
try {
open()
} catch (e: Exception) {
if (e is MuxerException) throw e
throw MuxerException("Could not open $path for muxing", e)
}
}
@@ -16,17 +16,16 @@ object ConversionRouter {
/**
* Containers Media3 can mux. Anything else has to go to FFmpeg.
*
* MP4 alone. This set used to name WebM, Ogg, WAV and AAC-ADTS as well, on the strength of
* `media3-muxer` shipping a muxer for each — but none of those four can be driven by
* Transformer at all, for the reasons `Media3Muxers` records. Nothing caught it because the
* engine ignored the container entirely and wrote MP4 regardless, so the set being wrong and
* the engine being wrong cancelled out.
*
* Not private: `Media3Muxers` has to supply a `Muxer.Factory` for every entry, and a test
* asserts the two agree. They drifted once already — this set was correct while the engine
* silently wrote MP4 for all five.
* asserts the two agree.
*/
internal val MEDIA3_CONTAINERS = setOf(
Container.MP4,
Container.WEBM,
Container.OGG,
Container.WAV,
Container.AAC_ADTS,
)
internal val MEDIA3_CONTAINERS = setOf(Container.MP4)
/** WebM is codec-restricted: Media3's WebmMuxer writes only these. */
private val WEBM_AUDIO = setOf(AudioCodec.OPUS, AudioCodec.VORBIS)