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
@@ -4,12 +4,17 @@ 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.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.CodecMode
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ContainerCapabilities
import org.libremediaconverter.model.ConversionRouter
import org.libremediaconverter.model.VideoCodec
/**
* Guards the agreement between the router's container set and the muxers that back it.
@@ -95,4 +100,92 @@ class Media3MuxersTest {
.getSupportedSampleMimeTypes(C.TRACK_TYPE_AUDIO),
)
}
/**
* The router's transcription of what Media3 can carry must match the factories themselves.
*
* `ConversionRouter` cannot import a `Muxer.Factory` — `model/` is deliberately free of Android
* and Media3 types so the routing rules stay JVM-testable — so it restates these sets by hand.
* Two hand-written answers to one question drift; this is what stops them.
*
* The check runs against the factory's own `getSupportedSampleMimeTypes`, which is what
* `Transformer` consults, so it is ground truth rather than another transcription.
*/
@Test
fun `the router's muxable sets match what the factories report`() {
ConversionRouter.MEDIA3_CONTAINERS.forEach { container ->
val factory = requireNotNull(Media3Muxers.factoryFor(container))
val reportedVideo = factory.getSupportedSampleMimeTypes(C.TRACK_TYPE_VIDEO)
.mapNotNull(::videoCodecOf).toSet()
val reportedAudio = factory.getSupportedSampleMimeTypes(C.TRACK_TYPE_AUDIO)
.mapNotNull(::audioCodecOf).toSet()
assertEquals(
"$container video",
reportedVideo,
ConversionRouter.MEDIA3_MUXABLE_VIDEO[container].orEmpty(),
)
assertEquals(
"$container audio",
reportedAudio,
ConversionRouter.MEDIA3_MUXABLE_AUDIO[container].orEmpty(),
)
}
}
/**
* Records the two places Media3's muxers and the app-wide matrix deliberately disagree.
*
* Neither is a bug, and pinning them is the point: if either moves, one of the two documents
* has changed its mind and somebody should say so on purpose.
*
* - Media3's MP4 muxer accepts Vorbis; [ContainerCapabilities] declines to offer it, because
* Vorbis-in-MP4 is poorly supported by players.
* - The matrix offers MP3 and FLAC in MP4, which is legal and which FFmpeg writes happily, but
* Media3's MP4 muxer carries neither — so those jobs route to FFmpeg rather than failing.
*/
@Test
fun `the documented divergences between Media3 and the matrix still hold`() {
val media3Mp4Audio = ConversionRouter.MEDIA3_MUXABLE_AUDIO.getValue(Container.MP4)
assertTrue(
"Media3 still takes Vorbis in MP4",
AudioCodec.VORBIS in media3Mp4Audio,
)
assertFalse(
"the matrix still declines to offer it",
ContainerCapabilities.accepts(Container.MP4, AudioCodec.VORBIS, CodecMode.COPY),
)
listOf(AudioCodec.MP3, AudioCodec.FLAC).forEach { codec ->
assertTrue(
"the matrix still allows $codec in MP4",
ContainerCapabilities.accepts(Container.MP4, codec, CodecMode.COPY),
)
assertFalse(
"Media3's MP4 muxer still cannot carry $codec",
codec in media3Mp4Audio,
)
}
}
private fun videoCodecOf(mime: String): VideoCodec? = when (mime) {
MimeTypes.VIDEO_H264 -> VideoCodec.H264
MimeTypes.VIDEO_H265 -> VideoCodec.H265
MimeTypes.VIDEO_VP8 -> VideoCodec.VP8
MimeTypes.VIDEO_VP9 -> VideoCodec.VP9
MimeTypes.VIDEO_AV1 -> VideoCodec.AV1
else -> null
}
private fun audioCodecOf(mime: String): AudioCodec? = when (mime) {
MimeTypes.AUDIO_AAC -> AudioCodec.AAC
MimeTypes.AUDIO_OPUS -> AudioCodec.OPUS
MimeTypes.AUDIO_VORBIS -> AudioCodec.VORBIS
MimeTypes.AUDIO_RAW -> AudioCodec.PCM
MimeTypes.AUDIO_MPEG -> AudioCodec.MP3
MimeTypes.AUDIO_FLAC -> AudioCodec.FLAC
else -> null
}
}
@@ -0,0 +1,86 @@
package org.libremediaconverter.convert
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.libremediaconverter.model.Container
/**
* Reading FFprobe's `format_name`, which is the only source of the source container.
*
* The trap is that FFprobe names every format sharing the demuxer, not the one the file actually
* is: a plain MP4 comes back as `mov,mp4,m4a,3gp,3g2,mj2`. Matching the whole string against a
* container name never succeeds, and taking the first entry reports every MP4 as a MOV.
*
* Getting this wrong fails silently rather than loudly — a null container just means `CopyPlanner`
* cannot tell the container is changing, so it declines to upgrade to a stream copy and everything
* is re-encoded. The feature would look like it simply did not work.
*/
class MediaProbeFormatTest {
@Test
fun `an MP4 is not mistaken for a MOV`() {
assertEquals(
Container.MP4,
MediaProbe.containerFrom("mov,mp4,m4a,3gp,3g2,mj2"),
)
}
@Test
fun `a real QuickTime file is recognised`() {
assertEquals(Container.MOV, MediaProbe.containerFrom("mov"))
}
/**
* WebM and Matroska share a demuxer and report the same format name.
*
* Both committed fixtures — an H.264 `.mkv` and a VP9 `.webm` — come back as `matroska,webm`,
* so the string alone cannot separate them. The codec is the only remaining signal: WebM
* permits VP8/VP9/AV1 and nothing else.
*/
@Test
fun `a Matroska file carrying H264 is not reported as WebM`() {
assertEquals(Container.MKV, MediaProbe.containerFrom("matroska,webm", "h264"))
}
@Test
fun `a WebM-legal codec in that container is reported as WebM`() {
assertEquals(Container.WEBM, MediaProbe.containerFrom("matroska,webm", "vp9"))
assertEquals(Container.WEBM, MediaProbe.containerFrom("matroska,webm", "av1"))
}
/** With no video track there is nothing to disambiguate on; Matroska is the general case. */
@Test
fun `an audio-only Matroska file is reported as Matroska`() {
assertEquals(Container.MKV, MediaProbe.containerFrom("matroska,webm", null))
}
@Test
fun `the remaining containers map to themselves`() {
assertEquals(Container.MPEG_TS, MediaProbe.containerFrom("mpegts"))
assertEquals(Container.AVI, MediaProbe.containerFrom("avi"))
assertEquals(Container.FLV, MediaProbe.containerFrom("flv"))
assertEquals(Container.ASF, MediaProbe.containerFrom("asf"))
assertEquals(Container.WAV, MediaProbe.containerFrom("wav"))
assertEquals(Container.MP3, MediaProbe.containerFrom("mp3"))
assertEquals(Container.FLAC, MediaProbe.containerFrom("flac"))
assertEquals(Container.OGG, MediaProbe.containerFrom("ogg"))
}
@Test
fun `whitespace and case do not matter`() {
assertEquals(Container.MP4, MediaProbe.containerFrom("MOV, MP4, M4A"))
}
/**
* Unknown means unknown, not a guess.
*
* A wrong container would let the copy planner believe the container is changing when it is
* not, or the reverse — and the planner's whole discipline is that it never acts on a guess.
*/
@Test
fun `an unrecognised format name yields null rather than a guess`() {
assertNull(MediaProbe.containerFrom("some_new_format"))
assertNull(MediaProbe.containerFrom(""))
}
}
@@ -1,7 +1,12 @@
package org.libremediaconverter.ffmpeg
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.model.QualityTier
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -14,14 +19,22 @@ class FFmpegCommandBuilderTest {
format: OutputFormat,
quality: QualityTier = QualityTier.BEST,
hardwareEncodeAvailable: Boolean = true,
): List<String> = cmd(format.spec, quality, hardwareEncodeAvailable)
private fun cmd(
spec: OutputSpec,
quality: QualityTier = QualityTier.BEST,
hardwareEncodeAvailable: Boolean = true,
probe: InputProbe = InputProbe(videoCodec = "h264", audioCodec = "aac"),
): List<String> = FFmpegCommandBuilder.build(
ConversionRequest(
format = format,
spec = spec,
quality = quality,
probe = probe,
hardwareEncodeAvailable = hardwareEncodeAvailable,
),
inputPath = "/cache/in.mp4",
outputPath = "/cache/out.${format.extension}",
outputPath = "/cache/out.${spec.extension}",
)
/** Asserts `flag` is present and immediately followed by `value`. */
@@ -183,7 +196,7 @@ class FFmpegCommandBuilderTest {
assertPair(args, "-vf", "fps=1")
assertEquals(
"shot_%04d.png",
FFmpegCommandBuilder.outputPattern(OutputFormat.FRAMES_PNG, "shot"),
FFmpegCommandBuilder.outputPattern(Container.IMAGE_SEQUENCE, "shot"),
)
}
@@ -191,7 +204,7 @@ class FFmpegCommandBuilderTest {
fun `single file formats keep their plain name`() {
assertEquals(
"clip.mp4",
FFmpegCommandBuilder.outputPattern(OutputFormat.MP4_H264, "clip.mp4"),
FFmpegCommandBuilder.outputPattern(Container.MP4, "clip.mp4"),
)
}
@@ -228,4 +241,154 @@ class FFmpegCommandBuilderTest {
}
}
}
// --- stream copy --------------------------------------------------------
@Test
fun `a pure remux copies both tracks and encodes neither`() {
val args = cmd(OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY))
assertPair(args, "-c:v", "copy")
assertPair(args, "-c:a", "copy")
assertFalse("a copy must not name an encoder: $args", args.contains("libx264"))
assertFalse(args.contains("libx265"))
assertFalse(args.contains("aac"))
}
/**
* A stream copy decodes nothing, so there are no frames to convert.
*
* Every encode path pins yuv420p for good reason, but carrying the flag onto the copy path
* would be meaningless at best and would force a decode at worst.
*/
@Test
fun `a stream copy does not pin a pixel format`() {
val args = cmd(OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY))
assertFalse("copying needs no -pix_fmt: $args", args.contains("-pix_fmt"))
}
@Test
fun `copying video while re-encoding audio does both`() {
val args = cmd(OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.OPUS))
assertPair(args, "-c:v", "copy")
assertPair(args, "-c:a", "libopus")
}
/**
* The brand matters on the copy path too.
*
* Remuxing HEVC out of Matroska into MP4 moves byte-identical samples, but without the hvc1
* brand Apple devices and many hardware players refuse the result.
*/
@Test
fun `copying HEVC into MP4 still tags it hvc1`() {
val args = cmd(
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY),
probe = InputProbe(videoCodec = "hevc", audioCodec = "aac", container = Container.MKV),
)
assertPair(args, "-c:v", "copy")
assertPair(args, "-tag:v", "hvc1")
}
@Test
fun `copying HEVC into Matroska does not tag it`() {
val args = cmd(
OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY),
probe = InputProbe(videoCodec = "hevc", audioCodec = "aac", container = Container.MP4),
)
assertFalse("hvc1 is an ISO-BMFF brand, not a Matroska one: $args", args.contains("-tag:v"))
}
@Test
fun `copying H264 into MP4 does not tag it hvc1`() {
val args = cmd(
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY),
probe = InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.MKV),
)
assertFalse(args.contains("hvc1"))
}
// --- explicit muxer selection -------------------------------------------
/**
* Every container names its muxer.
*
* The builder used to rely on FFmpeg inferring the format from the output extension, which is
* unreliable for MPEG-TS and ASF and became untenable once the container stopped being implied
* by the preset.
*/
@Test
fun `every container passes an explicit -f`() {
Container.entries.forEach { container ->
val spec = when {
container == Container.GIF || container == Container.IMAGE_SEQUENCE ->
OutputSpec(container, VideoCodec.NONE, AudioCodec.NONE)
container.canHoldVideo -> OutputSpec(container, VideoCodec.COPY, AudioCodec.COPY)
else -> OutputSpec(container, VideoCodec.NONE, AudioCodec.COPY)
}
assertPair(cmd(spec), "-f", container.ffmpegFormat)
}
}
@Test
fun `the new containers name the muxers FFmpeg actually uses`() {
assertEquals("matroska", Container.MKV.ffmpegFormat)
assertEquals("mpegts", Container.MPEG_TS.ffmpegFormat)
assertEquals("asf", Container.ASF.ffmpegFormat)
assertEquals("mov", Container.MOV.ffmpegFormat)
assertEquals("adts", Container.AAC_ADTS.ffmpegFormat)
}
@Test
fun `faststart applies to the MP4 family only`() {
assertTrue(cmd(OutputFormat.MP4_H264).contains("+faststart"))
assertTrue(
cmd(OutputSpec(Container.MOV, VideoCodec.COPY, AudioCodec.COPY))
.contains("+faststart"),
)
assertFalse(
cmd(OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY))
.contains("+faststart"),
)
}
// --- no silent substitution ---------------------------------------------
/**
* Asking for a codec this app cannot encode must fail, not quietly become H.264.
*
* The builder used to end its codec `when` with `else -> libx264`, which is the same shape as
* the `media3MimeType()` bug that put an HEVC video track in a file named `.m4a`: the user asks
* for one thing, gets another, and nothing reports it. VP8 and AV1 are copyable but not
* encodable, so they are exactly the requests that would land in that branch.
*/
@Test
fun `requesting an unencodable codec fails instead of substituting H264`() {
listOf(VideoCodec.VP8, VideoCodec.AV1).forEach { codec ->
val failure = runCatching {
cmd(OutputSpec(Container.MKV, codec, AudioCodec.AAC))
}.exceptionOrNull()
assertTrue(
"$codec should be refused, not silently encoded as something else",
failure != null,
)
assertTrue(
"the message should name the codec, got: ${failure?.message}",
failure?.message?.contains(codec.label) == true,
)
}
}
@Test
fun `the codecs this app does encode still produce their own encoder`() {
assertPair(cmd(OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.AAC)), "-c:v", "libx264")
assertPair(cmd(OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.AAC)), "-c:v", "libx265")
assertPair(
cmd(OutputSpec(Container.WEBM, VideoCodec.VP9, AudioCodec.OPUS)),
"-c:v",
"libvpx-vp9",
)
}
}
@@ -0,0 +1,51 @@
package org.libremediaconverter.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The bridge between what probing reports and what the app's enums mean.
*
* Three vocabularies meet: `MediaExtractor` MIME types, FFprobe `codec_name` strings, and the
* enums. Stream copy depends on the round trip, so a missing alias here shows up as "we could not
* identify the source codec" and silently costs the user a re-encode.
*/
class CodecNamesTest {
@Test
fun `the aliases both probes emit resolve to the same codec`() {
listOf("h264", "avc", "avc1", "AVC1").forEach {
assertEquals("$it should be H.264", VideoCodec.H264, CodecNames.videoFromName(it))
}
listOf("hevc", "h265", "hvc1", "hev1").forEach {
assertEquals("$it should be H.265", VideoCodec.H265, CodecNames.videoFromName(it))
}
assertEquals(VideoCodec.AV1, CodecNames.videoFromName("av01"))
assertEquals(AudioCodec.AAC, CodecNames.audioFromName("mp4a"))
assertEquals(AudioCodec.PCM, CodecNames.audioFromName("pcm_s16le"))
}
/** The sentinel must never look like a codec — that is the whole point of it. */
@Test
fun `the unparseable sentinel resolves to nothing`() {
assertNull(CodecNames.videoFromName(InputProbe.UNPARSEABLE))
}
@Test
fun `an unknown name resolves to nothing rather than a default`() {
assertNull(CodecNames.videoFromName("cinepak"))
assertNull(CodecNames.audioFromName("qdm2"))
assertNull(CodecNames.videoFromName(null))
}
/** The source-info card shows these, so they must never be blank or a raw sentinel. */
@Test
fun `descriptions stay readable for unknown and missing codecs`() {
assertEquals("H.264", CodecNames.describeVideo("h264"))
assertEquals("Unknown", CodecNames.describeVideo(null))
assertEquals("Unrecognised", CodecNames.describeVideo(InputProbe.UNPARSEABLE))
// An unrecognised but real codec name is more useful shown than hidden.
assertEquals("cinepak", CodecNames.describeVideo("cinepak"))
}
}
@@ -0,0 +1,171 @@
package org.libremediaconverter.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The matrix that replaced the closed format enum.
*
* `OutputFormat` used to be twelve hand-picked triples, and its KDoc defended that on the grounds
* that a closed set was what made routing decidable. Opening it up moves that burden here, so this
* is where decidability now has to be proven.
*/
class ContainerCapabilitiesTest {
private val h264Source = InputProbe(
videoCodec = "h264",
audioCodec = "aac",
container = Container.MP4,
)
// --- copy and encode are different questions ----------------------------
/**
* The case that makes the mode axis necessary.
*
* A single boolean would have to answer one way or the other, and either answer is wrong half
* the time: refusing AV1 in MP4 blocks a legitimate remux, allowing it promises an encode
* neither engine can deliver.
*/
@Test
fun `MP4 carries AV1 on copy but cannot encode it`() {
assertTrue(ContainerCapabilities.accepts(Container.MP4, VideoCodec.AV1, CodecMode.COPY))
assertFalse(ContainerCapabilities.accepts(Container.MP4, VideoCodec.AV1, CodecMode.ENCODE))
}
@Test
fun `Matroska carries Vorbis on copy but nothing here encodes it`() {
assertTrue(ContainerCapabilities.accepts(Container.MKV, AudioCodec.VORBIS, CodecMode.COPY))
assertFalse(
ContainerCapabilities.accepts(Container.MKV, AudioCodec.VORBIS, CodecMode.ENCODE),
)
}
@Test
fun `a codec the container cannot hold is refused in both modes`() {
listOf(CodecMode.COPY, CodecMode.ENCODE).forEach { mode ->
assertFalse(
"WebM should never accept H.264 ($mode)",
ContainerCapabilities.accepts(Container.WEBM, VideoCodec.H264, mode),
)
assertFalse(
"WAV should never accept AAC ($mode)",
ContainerCapabilities.accepts(Container.WAV, AudioCodec.AAC, mode),
)
}
}
@Test
fun `H265 in AVI is refused — AVI predates it`() {
assertFalse(ContainerCapabilities.accepts(Container.AVI, VideoCodec.H265, CodecMode.COPY))
assertTrue(ContainerCapabilities.accepts(Container.AVI, VideoCodec.H264, CodecMode.COPY))
}
@Test
fun `resolving COPY before asking the matrix is required`() {
// The matrix cannot answer for COPY; the caller has to resolve it against the probe first.
// Failing loudly is what stops a caller from silently getting "false" and refusing a
// perfectly good remux.
runCatching { ContainerCapabilities.accepts(Container.MP4, VideoCodec.COPY, CodecMode.COPY) }
.onSuccess { throw AssertionError("expected COPY to be rejected by the matrix") }
}
// --- validation ---------------------------------------------------------
@Test
fun `every preset is a valid spec`() {
OutputFormat.entries.forEach { preset ->
val result = ContainerCapabilities.validate(preset.spec, h264Source)
assertTrue("${preset.name} is not valid: $result", result.isValid)
}
}
/** A suggestion that is itself invalid is worse than no suggestion. */
@Test
fun `every suggestion is itself valid`() {
val broken = OutputSpec(Container.WEBM, VideoCodec.H264, AudioCodec.AAC)
val result = ContainerCapabilities.validate(broken, h264Source)
val invalid = result as? Validation.Invalid
?: throw AssertionError("expected H.264 in WebM to be rejected")
assertTrue("no alternatives offered", invalid.suggestions.isNotEmpty())
invalid.suggestions.forEach { suggestion ->
assertTrue(
"suggested $suggestion is itself invalid",
ContainerCapabilities.validate(suggestion, h264Source).isValid,
)
}
}
@Test
fun `an unidentifiable source codec cannot be copied`() {
val unknown = InputProbe(videoCodec = InputProbe.UNPARSEABLE, audioCodec = null)
val spec = OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.NONE)
val result = ContainerCapabilities.validate(spec, unknown)
assertFalse("copying an unknown codec must be refused", result.isValid)
}
@Test
fun `copying a video track into an audio-only container is refused`() {
val spec = OutputSpec(Container.MP3, VideoCodec.COPY, AudioCodec.MP3)
val result = ContainerCapabilities.validate(spec, h264Source)
val invalid = result as? Validation.Invalid
?: throw AssertionError("expected video in MP3 to be rejected")
assertTrue(invalid.message.contains("audio only"))
}
@Test
fun `dropping both tracks is refused rather than producing an empty file`() {
val spec = OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.NONE)
val result = ContainerCapabilities.validate(spec, h264Source)
assertFalse(result.isValid)
assertTrue((result as Validation.Invalid).suggestions.isNotEmpty())
}
@Test
fun `copying is offered as the fix when the codec is right but unencodable`() {
val av1Source = InputProbe(videoCodec = "av1", audioCodec = "aac", container = Container.MKV)
val spec = OutputSpec(Container.MP4, VideoCodec.AV1, AudioCodec.AAC)
val invalid = ContainerCapabilities.validate(spec, av1Source) as? Validation.Invalid
?: throw AssertionError("expected an AV1 encode to be rejected")
assertTrue(
"should offer to copy the AV1 track instead, got ${invalid.suggestions}",
invalid.suggestions.any { it.videoCodec == VideoCodec.COPY },
)
}
// --- the picker reads these ---------------------------------------------
@Test
fun `encodable lists never contain a codec the container cannot hold`() {
Container.entries.forEach { container ->
ContainerCapabilities.encodableVideo(container).forEach {
assertTrue(
"$container claims to encode $it but cannot hold it",
ContainerCapabilities.accepts(container, it, CodecMode.COPY),
)
}
ContainerCapabilities.encodableAudio(container).forEach {
assertTrue(
"$container claims to encode $it but cannot hold it",
ContainerCapabilities.accepts(container, it, CodecMode.COPY),
)
}
}
}
@Test
fun `audio-only containers offer no video codecs`() {
listOf(Container.MP3, Container.WAV, Container.FLAC, Container.OGG, Container.AAC_ADTS)
.forEach { container ->
assertFalse("$container claims to hold video", container.canHoldVideo)
assertEquals(emptyList<VideoCodec>(), ContainerCapabilities.encodableVideo(container))
}
}
}
@@ -20,7 +20,18 @@ class ConversionRouterTest {
probe: InputProbe = InputProbe(videoCodec = "h264"),
device: DeviceCodecs = DeviceCodecs.PERMISSIVE,
) = ConversionRouter.route(
ConversionRequest(format, quality, preference, probe),
ConversionRequest(format.spec, quality, preference, probe),
device,
)
private fun route(
spec: OutputSpec,
quality: QualityTier = QualityTier.FAST,
preference: EnginePreference = EnginePreference.AUTO,
probe: InputProbe = InputProbe(videoCodec = "h264"),
device: DeviceCodecs = DeviceCodecs.PERMISSIVE,
) = ConversionRouter.route(
ConversionRequest(spec, quality, preference, probe),
device,
)
@@ -173,6 +184,126 @@ class ConversionRouterTest {
assertEquals(Reason.USER_FORCED_SOFTWARE, d.reason)
}
// --- remux --------------------------------------------------------------
/**
* The test that keeps the Media3 transmux path alive.
*
* `COPY` belongs to none of the router's capability sets, so the obvious implementation — test
* the request's codecs directly — sends every remux to FFmpeg on the very first check. Nothing
* would fail: FFmpeg's `-c copy` produces a correct file, just on the CPU. Only an assertion
* about the *engine* catches it, which is why this one exists.
*/
@Test
fun `a full stream copy into a Media3 container stays on hardware`() {
val d = route(
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY),
probe = InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.MKV),
)
assertEquals(Engine.MEDIA3, d.engine)
assertEquals(Reason.REMUX_NO_REENCODE, d.reason)
}
/** A copy needs no encoder at all, so a device with none must not push it to software. */
@Test
fun `a stream copy ignores the device encoder capabilities`() {
val noEncoders = object : DeviceCodecs {
override fun canEncode(codec: VideoCodec) = false
override fun canDecode(codecName: String) = true
}
val d = route(
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY),
probe = InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.MKV),
device = noEncoders,
)
assertEquals(Engine.MEDIA3, d.engine)
}
/** Nor does it decode, so a codec the device cannot decode is still copyable. */
@Test
fun `a stream copy ignores the device decoder capabilities`() {
val noDecoders = object : DeviceCodecs {
override fun canEncode(codec: VideoCodec) = true
override fun canDecode(codecName: String) = false
}
val d = route(
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY),
probe = InputProbe(videoCodec = "av1", audioCodec = "aac", container = Container.MKV),
device = noDecoders,
)
assertEquals(Engine.MEDIA3, d.engine)
}
/**
* Copying MP3 audio into MP4 needs no encoder — but Media3's MP4 muxer cannot carry MP3.
*
* Two separate limits that used to be indistinguishable because nothing could reach this
* combination. The encoder gap is real and permanent (Android has no MP3 encoder); the muxer
* gap is what actually decides this job, and the reason has to say so rather than blaming an
* encoder nobody asked for.
*/
@Test
fun `copying MP3 audio into MP4 needs FFmpeg because Media3 cannot mux it`() {
val d = route(
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY),
probe = InputProbe(videoCodec = "h264", audioCodec = "mp3", container = Container.MKV),
)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.CONTAINER_CODEC_UNSUPPORTED, d.reason)
}
/** Opus, by contrast, Media3's MP4 muxer does carry. */
@Test
fun `copying Opus audio into MP4 stays on hardware`() {
val d = route(
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY),
probe = InputProbe(videoCodec = "h264", audioCodec = "opus", container = Container.MKV),
)
assertEquals(Engine.MEDIA3, d.engine)
assertEquals(Reason.REMUX_NO_REENCODE, d.reason)
}
/** But Best quality is meaningless for a copy, so it must not force software. */
@Test
fun `best quality does not force software for a pure remux`() {
val d = route(
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY),
quality = QualityTier.BEST,
probe = InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.MKV),
)
assertEquals(Engine.MEDIA3, d.engine)
}
/** An unreadable file cannot be copied either — there is nothing to demux. */
@Test
fun `an unparseable input still goes to FFmpeg even when a copy was asked for`() {
val d = route(
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY),
probe = InputProbe(videoCodec = InputProbe.UNPARSEABLE),
)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.NO_PLATFORM_DECODER, d.reason)
}
@Test
fun `the new containers are all FFmpeg-only`() {
listOf(
Container.MOV, Container.MKV, Container.MPEG_TS,
Container.AVI, Container.FLV, Container.ASF,
).forEach { container ->
val d = route(
OutputSpec(container, VideoCodec.COPY, AudioCodec.COPY),
probe = InputProbe(
videoCodec = "h264",
audioCodec = "aac",
container = Container.MP4,
),
)
assertEquals("$container should need FFmpeg", Engine.FFMPEG, d.engine)
assertEquals(Reason.CONTAINER_UNSUPPORTED, d.reason)
}
}
// --- exhaustiveness ----------------------------------------------------
@Test
@@ -0,0 +1,159 @@
package org.libremediaconverter.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The stream-copy decision for a single file.
*
* Deliberately mirrors [ConcatPlannerTest]'s shape, because it enforces the same rule for the same
* reason: an unproven match must never become a stream copy. A needless re-encode costs time; a
* wrong copy costs the user a file that will not play, and they may not notice until the source is
* gone.
*/
class CopyPlannerTest {
private val mp4H264 = InputProbe(
videoCodec = "h264",
audioCodec = "aac",
container = Container.MP4,
)
// --- explicit copy ------------------------------------------------------
@Test
fun `asking to copy a known codec the container accepts copies it`() {
val plan = CopyPlanner.plan(
OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY),
mp4H264,
)
assertEquals(VideoPlan.Copy, plan.video)
assertEquals(AudioPlan.Copy, plan.audio)
assertTrue("this is a pure container change", plan.isPureRemux)
}
@Test
fun `an unparseable source is never copied`() {
val plan = CopyPlanner.plan(
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY),
InputProbe(videoCodec = InputProbe.UNPARSEABLE, audioCodec = null),
)
assertTrue("must not copy an unidentified stream", plan.video !is VideoPlan.Copy)
assertTrue(plan.audio !is AudioPlan.Copy)
}
/** Two unknowns are not evidence of agreement — the rule ConcatPlanner already states. */
@Test
fun `an unrecognised codec name falls back to re-encoding`() {
val exotic = InputProbe(videoCodec = "cinepak", audioCodec = "qdm2", container = Container.MOV)
val plan = CopyPlanner.plan(
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY),
exotic,
)
assertTrue(plan.video is VideoPlan.Encode)
assertTrue(plan.audio is AudioPlan.Encode)
}
@Test
fun `copying into a container that cannot hold the codec re-encodes instead`() {
// H.264 cannot go in WebM. Asking to copy anyway must not produce a broken file.
val plan = CopyPlanner.plan(
OutputSpec(Container.WEBM, VideoCodec.COPY, AudioCodec.NONE),
mp4H264,
)
assertEquals(VideoPlan.Encode(VideoCodec.VP9), plan.video)
}
// --- the auto-upgrade rule ----------------------------------------------
@Test
fun `a matching codec with a changing container is upgraded to a copy`() {
val plan = CopyPlanner.plan(
OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.AAC),
mp4H264,
)
assertEquals("MP4 h264 -> MKV h264 is a remux", VideoPlan.Copy, plan.video)
assertEquals(AudioPlan.Copy, plan.audio)
}
/**
* The case that stops auto-upgrade from breaking compression.
*
* Same container, same codec means the only reason to run the job is to re-encode it — almost
* always to make it smaller. Copying would hand back a byte-identical file and call it done.
*/
@Test
fun `a matching codec in the same container still re-encodes`() {
val plan = CopyPlanner.plan(
OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.AAC),
mp4H264,
)
assertEquals(VideoPlan.Encode(VideoCodec.H264), plan.video)
assertEquals(AudioPlan.Encode(AudioCodec.AAC), plan.audio)
}
@Test
fun `an unknown source container never triggers auto-upgrade`() {
// Without a source container there is no way to know the container is changing, so the
// conservative answer is to encode.
val noContainer = InputProbe(videoCodec = "h264", audioCodec = "aac", container = null)
val plan = CopyPlanner.plan(
OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.AAC),
noContainer,
)
assertEquals(VideoPlan.Encode(VideoCodec.H264), plan.video)
}
@Test
fun `a different codec is always re-encoded`() {
val plan = CopyPlanner.plan(
OutputSpec(Container.MKV, VideoCodec.H265, AudioCodec.OPUS),
mp4H264,
)
assertEquals(VideoPlan.Encode(VideoCodec.H265), plan.video)
assertEquals(AudioPlan.Encode(AudioCodec.OPUS), plan.audio)
}
// --- track removal ------------------------------------------------------
@Test
fun `audio-only output drops the video track`() {
val plan = CopyPlanner.plan(
OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.AAC),
mp4H264,
)
assertEquals(VideoPlan.Drop, plan.video)
assertTrue(plan.audio is AudioPlan.Encode)
assertTrue("dropping plus encoding is not a remux", !plan.isPureRemux)
}
@Test
fun `a source with no video track drops video even when video was requested`() {
val audioOnly = InputProbe(
videoCodec = null,
audioCodec = "aac",
hasVideo = false,
container = Container.MP4,
kind = InputKind.AUDIO_ONLY,
)
val plan = CopyPlanner.plan(
OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.COPY),
audioOnly,
)
assertEquals(VideoPlan.Drop, plan.video)
assertEquals(AudioPlan.Copy, plan.audio)
assertTrue("copying the only track is still a remux", plan.isPureRemux)
}
@Test
fun `copying one track and encoding the other is not a pure remux`() {
val plan = CopyPlanner.plan(
OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.OPUS),
mp4H264,
)
assertEquals(VideoPlan.Copy, plan.video)
assertEquals(AudioPlan.Encode(AudioCodec.OPUS), plan.audio)
assertTrue(!plan.isPureRemux)
}
}
@@ -61,4 +61,66 @@ class OutputFormatTest {
assertTrue(it.description.isNotBlank())
}
}
// --- containers are now load-bearing ------------------------------------
/**
* Extension and MIME type moved from the preset onto the container.
*
* They used to be per-preset literals, which is why `OutputFormat.FLAC` could declare
* `Container.MKV` with extension `flac` and nobody noticed — nothing read the container. Now
* `-f`, the filename and the SAF create-document MIME all derive from it.
*/
@Test
fun `every container names an extension, a mime type and an ffmpeg muxer`() {
Container.entries.forEach { container ->
listOf(true, false).forEach { hasVideo ->
val ext = container.extensionFor(hasVideo)
assertTrue("$container has no extension", ext.isNotBlank())
assertFalse("$container extension has a dot", ext.startsWith("."))
assertTrue(
"$container has no mime type",
container.mimeTypeFor(hasVideo).contains('/'),
)
}
assertTrue("$container names no muxer", container.ffmpegFormat.isNotBlank())
}
}
@Test
fun `audio-only variants of a container get their own extension`() {
assertEquals("mp4", Container.MP4.extensionFor(hasVideo = true))
assertEquals("m4a", Container.MP4.extensionFor(hasVideo = false))
assertEquals("mkv", Container.MKV.extensionFor(hasVideo = true))
assertEquals("mka", Container.MKV.extensionFor(hasVideo = false))
}
/** Regression guard: FLAC used to be declared as Matroska with a `.flac` extension. */
@Test
fun `FLAC is its own container, not Matroska`() {
assertEquals(Container.FLAC, OutputFormat.FLAC.container)
assertEquals("flac", OutputFormat.FLAC.extension)
assertEquals("flac", Container.FLAC.ffmpegFormat)
}
@Test
fun `the remux presets copy both tracks`() {
listOf(OutputFormat.REMUX_MP4, OutputFormat.REMUX_MKV).forEach {
assertEquals(VideoCodec.COPY, it.videoCodec)
assertEquals(AudioCodec.COPY, it.audioCodec)
assertTrue("${it.name} should be a pure remux", it.spec.isPureRemux)
}
}
@Test
fun `a spec that encodes anything is not a remux`() {
assertFalse(OutputFormat.MP4_H265.spec.isPureRemux)
assertFalse(
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.AAC).isPureRemux,
)
assertFalse(
"dropping both tracks copies nothing",
OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.NONE).isPureRemux,
)
}
}