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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user