Files
LibreMediaConverter/app/src/test/java/org/libremediaconverter/model/CopyPlannerTest.kt
T
JMR-devandClaude Opus 5 9c809d4e16 Refuse a spec that would leave the output with no tracks at all
Validation already refused two ways of asking for an empty file: None on
both codec axes, and Copy for a video track the input does not have. It
missed the third, because it read only the spec. Name H.265 with the
audio off, hand it an MP3, and the spec looks fine — it names a video
codec — while CopyPlanner drops that track anyway, because the *input*
has no video to encode. The plan is (Drop, Drop), the router still says
Media3, and EditedMediaItem.Builder refuses to build a composition with
both tracks removed. It refuses it on Transformer's own HandlerThread,
where the user sees the app die rather than a reason.

Asking the probe as well as the spec catches all three faces with one
guard, and the equivalence is exact rather than approximate: CopyPlanner
drops video for None or for an input with none, and audio for None, so
"(Drop, Drop)" and this condition are the same set. A sweep over every
non-image container by codec by codec against both probes asserts that,
so a new container or codec cannot reopen the gap on an axis nobody
wrote a case for.

This newly refuses a combination the Advanced picker accepts today, and
that is the point: today it crashes. What it must not do is refuse
without a way out. The Copy face had one only nominally — its single
hand-built suggestion was None + None, which validation rejects in the
next breath, so the one-tap fix fixed nothing. All three faces now go
through the shared repair-and-filter path, which for an MP3 into MP4
offers "copy the audio across" and nothing that has to be re-refused.

Repair is also stopped from naming a video codec for a file with no
video track. It used to fall through to the first codec the container
could encode, so the fix offered for an MP3 was "H.264" — a codec
CopyPlanner then drops, making the offer a fiction that happened to
validate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:49:16 -05:00

187 lines
6.9 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)
}
/**
* The one plan `Media3Engine` cannot be handed.
*
* `EditedMediaItem.Builder` refuses a composition with both tracks removed —
* checkState("Audio and video cannot both be removed") — and this is how an ordinary-looking
* spec reaches it: a video codec named for a file that has no video, with the audio switched
* off. Neither half is unusual on its own, which is why validation could read the spec, see a
* video codec, and call it fine.
*/
@Test
fun `an audio-only source with the audio dropped removes both tracks`() {
val audioOnly = InputProbe(
videoCodec = null,
audioCodec = "mp3",
hasVideo = false,
container = Container.MP3,
kind = InputKind.AUDIO_ONLY,
)
val plan = CopyPlanner.plan(
OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE),
audioOnly,
)
assertEquals(VideoPlan.Drop, plan.video)
assertEquals(AudioPlan.Drop, plan.audio)
assertTrue("an empty plan is not 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)
}
}