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>
187 lines
6.9 KiB
Kotlin
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)
|
|
}
|
|
}
|