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>
160 lines
5.8 KiB
Kotlin
160 lines
5.8 KiB
Kotlin
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)
|
|
}
|
|
}
|