Merge Media3's MP4-only correction into the remux branch

CI on the parent branch proved that four of the five containers the router
claimed for Media3 cannot be written by Transformer at all: WebmMuxer, OggMuxer,
WavMuxer and AacMuxer each throw UnsupportedOperationException from
addMetadataEntry, which MuxerWrapper calls for every metadata entry on the track
format.

Consequences here beyond the merge itself:

- MEDIA3_MUXABLE_VIDEO and MEDIA3_MUXABLE_AUDIO drop to a single MP4 entry.
  Every other container is already on its way to FFmpeg before those maps are
  consulted.
- Reason.WEBM_CODEC_UNSUPPORTED is removed. WebM now fails the container check
  first, so nothing could ever produce that reason, and a routing reason no code
  path can reach is worse than no reason at all.
- Media3Muxers gains null branches for the six containers this branch adds. MOV
  is among them despite being MP4's own family: Mp4Muxer exposes no QuickTime
  file format.
- The README no longer claims Media3 writes five containers.

The remux behaviour this branch exists for is unaffected: MKV -> MP4 was always
the hardware direction, because Media3 reads Matroska but has never been able to
write it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 07:28:59 -05:00
co-authored by Claude Opus 5
6 changed files with 101 additions and 226 deletions
@@ -1,56 +1,59 @@
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
// Everything else has no Media3 muxer. Matroska and the legacy containers are FFmpeg's,
// and `Mp4Muxer` exposes no QuickTime file format, so MOV is too.
// The four muxers described above, plus the containers Media3 never had one for. MOV is
// among them despite being MP4's own family: `Mp4Muxer` exposes only FILE_FORMAT_DEFAULT
// and FILE_FORMAT_MP4_WITH_AUXILIARY_TRACKS_EXTENSION — no QuickTime.
Container.WEBM,
Container.OGG,
Container.WAV,
Container.AAC_ADTS,
Container.MKV,
Container.MOV,
Container.MPEG_TS,
@@ -63,73 +66,4 @@ object Media3Muxers {
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)
}
}
@@ -25,17 +25,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)
/**
* What Media3's muxers can *carry*, as distinct from what Media3 can encode.
@@ -45,25 +44,21 @@ object ConversionRouter {
* — legal, and something FFmpeg does without complaint — has to leave the hardware path. Before
* remuxing existed nothing could reach that combination, so nothing had to know.
*
* Transcribed from each `Muxer.Factory.getSupportedSampleMimeTypes` in `Media3Muxers`;
* One entry, because MP4 is the only container Transformer can write — see `Media3Muxers` for
* why the four other muxers in `media3-muxer` cannot be driven by it. Every other container
* has already been sent to FFmpeg by the time these are consulted.
*
* Transcribed from `Muxer.Factory.getSupportedSampleMimeTypes` in `Media3Muxers`;
* `Media3MuxersTest` asserts the transcription still matches.
*/
internal val MEDIA3_MUXABLE_VIDEO: Map<Container, Set<VideoCodec>> = mapOf(
Container.MP4 to setOf(VideoCodec.H264, VideoCodec.H265, VideoCodec.VP9, VideoCodec.AV1),
Container.WEBM to setOf(VideoCodec.VP8, VideoCodec.VP9),
Container.OGG to emptySet(),
Container.WAV to emptySet(),
Container.AAC_ADTS to emptySet(),
)
internal val MEDIA3_MUXABLE_AUDIO: Map<Container, Set<AudioCodec>> = mapOf(
Container.MP4 to setOf(
AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.VORBIS, AudioCodec.PCM,
),
Container.WEBM to setOf(AudioCodec.OPUS, AudioCodec.VORBIS),
Container.OGG to setOf(AudioCodec.OPUS, AudioCodec.VORBIS),
Container.WAV to setOf(AudioCodec.PCM),
Container.AAC_ADTS to setOf(AudioCodec.AAC),
)
/** Video codecs Media3 can encode (`Transformer.setVideoMimeType`). */
@@ -109,15 +104,10 @@ object ConversionRouter {
// The container is supported but its muxer is codec-restricted. What matters is what ends
// up in the file, so a copied track is judged by its source codec rather than the request.
// Only MP4 reaches here now — WebM is rejected by the container check above — so the
// WebM-specific wording no longer applies.
if (!media3CanMux(plan, request.probe)) {
return Decision(
Engine.FFMPEG,
if (plan.container == Container.WEBM) {
Reason.WEBM_CODEC_UNSUPPORTED
} else {
Reason.CONTAINER_CODEC_UNSUPPORTED
},
)
return Decision(Engine.FFMPEG, Reason.CONTAINER_CODEC_UNSUPPORTED)
}
// A file the platform extractor could not open cannot be read at all, copied or not.
@@ -195,7 +185,6 @@ object ConversionRouter {
HARDWARE_CAPABLE("Hardware accelerated"),
REMUX_NO_REENCODE("Remuxed — streams copied, nothing re-encoded"),
CONTAINER_UNSUPPORTED("This container needs FFmpeg"),
WEBM_CODEC_UNSUPPORTED("WebM only supports VP8/VP9 with Opus or Vorbis"),
CONTAINER_CODEC_UNSUPPORTED("That codec needs FFmpeg for this container"),
NO_PLATFORM_ENCODER("Android has no encoder for this format"),
NO_PLATFORM_DECODER("This device cannot decode the input in hardware"),