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
@@ -19,9 +19,10 @@ import org.libremediaconverter.model.VideoCodec
/**
* 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.
@@ -51,54 +52,18 @@ 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)
}
/**
@@ -50,13 +50,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
@@ -304,6 +307,31 @@ class ConversionRouterTest {
}
}
// --- 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