transcode() posts its work to a HandlerThread, and everything on that thread has no caller to throw back to: an escaping exception reaches the thread's uncaught handler and takes the process down, while the continuation is never resumed. Both halves of that are bad, and the second is arguably worse — a worker left suspended forever holds a foreground service. The guarding was two narrow runCatching blocks, one around buildTransformer and one around transformer.start, with the two Media3 builders sitting unguarded between them. That gap was not theoretical. EditedMediaItem.Builder rejects a composition with both tracks removed, which is exactly what a plan of (Drop, Drop) asks for, and it does so with a plain IllegalStateException from the constructor. Validation now refuses the spec that produces such a plan, so neither the picker nor ConversionWorker will start one. Routing is a separate question and still answers Media3 for it — a dropped track makes nothing un-hardware-able — so a request that skips validation still arrives here: a job queued before the settings changed, or one made through ConversionWorker.request directly. CopyPlanner's own KDoc already names that path as the reason it re-checks what validation has checked; this is the same belt for the same braces. One guard around the whole body costs nothing on success and turns any such refusal into a failed job with a reason attached. The export body moves into startExport, whose contract is the thing that makes one guard enough: returning normally means the export is running and the listener owns the continuation, throwing means it never started and the caller does. Cancellation is still registered before start. Covered twice on purpose. Robolectric runs the real HandlerThread and the real Media3 builders, so the JVM test exercises the whole sequence and can be run anywhere; the instrumented one repeats it against the real framework. Neither asserts only that the failure is an IllegalStateException, because withTimeout raises TimeoutCancellationException and java.util.concurrent.CancellationException extends IllegalStateException — so that assertion alone calls an unresumed continuation a pass. Both were written that way first, and reverting the guard is what exposed it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
389 lines
15 KiB
Kotlin
389 lines
15 KiB
Kotlin
package org.libremediaconverter.model
|
|
|
|
import org.junit.Assert.assertEquals
|
|
import org.junit.Test
|
|
import org.libremediaconverter.model.ConversionRouter.Reason
|
|
|
|
/**
|
|
* One test per routing predicate.
|
|
*
|
|
* These run on the JVM against fabricated device profiles rather than on hardware, so
|
|
* every branch is reachable — including "this device cannot encode AV1", which would
|
|
* otherwise depend on which phone the suite happened to run on.
|
|
*/
|
|
class ConversionRouterTest {
|
|
|
|
private fun route(
|
|
format: OutputFormat,
|
|
quality: QualityTier = QualityTier.FAST,
|
|
preference: EnginePreference = EnginePreference.AUTO,
|
|
probe: InputProbe = InputProbe(videoCodec = "h264"),
|
|
device: DeviceCodecs = DeviceCodecs.PERMISSIVE,
|
|
) = ConversionRouter.route(
|
|
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,
|
|
)
|
|
|
|
// --- the happy path -----------------------------------------------------
|
|
|
|
@Test
|
|
fun `mp4 h264 on a capable device uses hardware`() {
|
|
val d = route(OutputFormat.MP4_H264)
|
|
assertEquals(Engine.MEDIA3, d.engine)
|
|
assertEquals(Reason.HARDWARE_CAPABLE, d.reason)
|
|
}
|
|
|
|
@Test
|
|
fun `mp4 h265 uses hardware`() {
|
|
assertEquals(Engine.MEDIA3, route(OutputFormat.MP4_H265).engine)
|
|
}
|
|
|
|
@Test
|
|
fun `webm vp9 routes to ffmpeg`() {
|
|
// Two independent reasons, either of which is sufficient: Transformer.setVideoMimeType
|
|
// accepts only H.263/H.264/H.265/MP4V, so there is no VP9 encoder — and Media3 cannot mux
|
|
// WebM at all, which it was previously credited with. The container check runs first and
|
|
// is the more fundamental of the two: even given a VP9 encoder, the file could not be
|
|
// written. This asserted NO_PLATFORM_ENCODER while the container was wrongly believed to
|
|
// be supported.
|
|
val d = route(OutputFormat.WEBM_VP9)
|
|
assertEquals(Engine.FFMPEG, d.engine)
|
|
assertEquals(Reason.CONTAINER_UNSUPPORTED, d.reason)
|
|
}
|
|
|
|
@Test
|
|
fun `aac audio extraction stays on hardware`() {
|
|
assertEquals(Engine.MEDIA3, route(OutputFormat.M4A_AAC).engine)
|
|
}
|
|
|
|
// --- 1. container ------------------------------------------------------
|
|
|
|
@Test
|
|
fun `mkv routes to ffmpeg because media3 has no matroska muxer`() {
|
|
val d = route(OutputFormat.MKV_H264)
|
|
assertEquals(Engine.FFMPEG, d.engine)
|
|
assertEquals(Reason.CONTAINER_UNSUPPORTED, d.reason)
|
|
}
|
|
|
|
// --- 3. no platform encoder -------------------------------------------
|
|
|
|
@Test
|
|
fun `mp3 routes to ffmpeg because android has no mp3 encoder at any api level`() {
|
|
val d = route(OutputFormat.MP3)
|
|
assertEquals(Engine.FFMPEG, d.engine)
|
|
assertEquals(Reason.NO_PLATFORM_ENCODER, d.reason)
|
|
}
|
|
|
|
@Test
|
|
fun `flac routes to ffmpeg`() {
|
|
assertEquals(Engine.FFMPEG, route(OutputFormat.FLAC).engine)
|
|
}
|
|
|
|
// --- 4. image output ---------------------------------------------------
|
|
|
|
@Test
|
|
fun `gif routes to ffmpeg as an image output`() {
|
|
val d = route(OutputFormat.GIF)
|
|
assertEquals(Engine.FFMPEG, d.engine)
|
|
assertEquals(Reason.IMAGE_OUTPUT, d.reason)
|
|
}
|
|
|
|
@Test
|
|
fun `png frame export routes to ffmpeg`() {
|
|
assertEquals(Engine.FFMPEG, route(OutputFormat.FRAMES_PNG).engine)
|
|
}
|
|
|
|
// --- 5. no platform decoder -------------------------------------------
|
|
|
|
@Test
|
|
fun `av1 input on a device without av1 decode routes to ffmpeg`() {
|
|
val noAv1 = object : DeviceCodecs {
|
|
override fun canEncode(codec: VideoCodec) = true
|
|
override fun canDecode(codecName: String) = codecName != "av1"
|
|
}
|
|
val d = route(OutputFormat.MP4_H264, probe = InputProbe(videoCodec = "av1"), device = noAv1)
|
|
assertEquals(Engine.FFMPEG, d.engine)
|
|
assertEquals(Reason.NO_PLATFORM_DECODER, d.reason)
|
|
}
|
|
|
|
@Test
|
|
fun `av1 input on a device with av1 decode stays on hardware`() {
|
|
val d = route(OutputFormat.MP4_H264, probe = InputProbe(videoCodec = "av1"))
|
|
assertEquals(Engine.MEDIA3, d.engine)
|
|
}
|
|
|
|
@Test
|
|
fun `an input the platform cannot parse routes to ffmpeg`() {
|
|
// MediaProbe reports this when the platform extractor fails to open the file.
|
|
// Media3 cannot convert what the platform cannot read, so the job must not be
|
|
// sent down the hardware path only to fail there.
|
|
val d = route(
|
|
OutputFormat.MP4_H264,
|
|
probe = InputProbe(videoCodec = InputProbe.UNPARSEABLE),
|
|
)
|
|
assertEquals(Engine.FFMPEG, d.engine)
|
|
assertEquals(Reason.NO_PLATFORM_DECODER, d.reason)
|
|
}
|
|
|
|
@Test
|
|
fun `a null input codec is treated as no information, not as a failure`() {
|
|
// Nothing known about the input is different from "known to be unreadable":
|
|
// the former should still take the fast path.
|
|
val d = route(OutputFormat.MP4_H264, probe = InputProbe(videoCodec = null))
|
|
assertEquals(Engine.MEDIA3, d.engine)
|
|
}
|
|
|
|
// --- 6. quality tier ---------------------------------------------------
|
|
|
|
@Test
|
|
fun `best quality routes to ffmpeg because mediacodec exposes no crf`() {
|
|
val d = route(OutputFormat.MP4_H264, quality = QualityTier.BEST)
|
|
assertEquals(Engine.FFMPEG, d.engine)
|
|
assertEquals(Reason.QUALITY_TIER_REQUIRES_CRF, d.reason)
|
|
}
|
|
|
|
@Test
|
|
fun `fast quality is the hardware path`() {
|
|
assertEquals(Engine.MEDIA3, route(OutputFormat.MP4_H264, quality = QualityTier.FAST).engine)
|
|
}
|
|
|
|
// --- 7. hardware encoder availability ---------------------------------
|
|
|
|
@Test
|
|
fun `h265 target on a device without hevc encode falls back to ffmpeg`() {
|
|
val noHevc = object : DeviceCodecs {
|
|
override fun canEncode(codec: VideoCodec) = codec != VideoCodec.H265
|
|
override fun canDecode(codecName: String) = true
|
|
}
|
|
val d = route(OutputFormat.MP4_H265, device = noHevc)
|
|
assertEquals(Engine.FFMPEG, d.engine)
|
|
assertEquals(Reason.NO_HARDWARE_ENCODER, d.reason)
|
|
}
|
|
|
|
// --- user override -----------------------------------------------------
|
|
|
|
@Test
|
|
fun `forcing software overrides an otherwise hardware-capable job`() {
|
|
val d = route(OutputFormat.MP4_H264, preference = EnginePreference.FORCE_SOFTWARE)
|
|
assertEquals(Engine.FFMPEG, d.engine)
|
|
assertEquals(Reason.USER_FORCED_SOFTWARE, d.reason)
|
|
}
|
|
|
|
@Test
|
|
fun `forcing software wins even for formats ffmpeg would take anyway`() {
|
|
val d = route(OutputFormat.MP3, preference = EnginePreference.FORCE_SOFTWARE)
|
|
assertEquals(Engine.FFMPEG, d.engine)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// --- containers Media3 cannot write -------------------------------------
|
|
|
|
/**
|
|
* WAV, Opus and raw AAC used to be claimed for Media3 and are not any more.
|
|
*
|
|
* `media3-muxer` ships a muxer for each, which is why they were listed — but none can be driven
|
|
* by Transformer, so the export dies at the muxer. They were never actually produced on the
|
|
* hardware path: the engine ignored the container and wrote MP4 into a file named `.wav`.
|
|
* FFmpeg produces all three, and `FFmpegEngineTest` asserts the produced files.
|
|
*/
|
|
@Test
|
|
fun `audio containers Media3 cannot mux route to FFmpeg`() {
|
|
listOf(OutputFormat.WAV, OutputFormat.OPUS).forEach { format ->
|
|
val d = route(format)
|
|
assertEquals("$format should need FFmpeg", Engine.FFMPEG, d.engine)
|
|
assertEquals(Reason.CONTAINER_UNSUPPORTED, d.reason)
|
|
}
|
|
}
|
|
|
|
/** M4A is the audio format that does stay on hardware, because its container is MP4. */
|
|
@Test
|
|
fun `m4a stays on hardware because MP4 is a container Media3 can write`() {
|
|
assertEquals(Engine.MEDIA3, route(OutputFormat.M4A_AAC).engine)
|
|
}
|
|
|
|
// --- exhaustiveness ----------------------------------------------------
|
|
|
|
@Test
|
|
fun `every output format routes somewhere without throwing`() {
|
|
OutputFormat.entries.forEach { format ->
|
|
val decision = route(format)
|
|
assertEquals(
|
|
"format $format produced no engine",
|
|
true,
|
|
decision.engine == Engine.MEDIA3 || decision.engine == Engine.FFMPEG,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Why `Media3Engine` still needs a guard of its own.
|
|
*
|
|
* `ContainerCapabilities.validate` now refuses "a video codec with the audio off" for an input
|
|
* with no video track, so neither the picker nor `ConversionWorker` will start one. Routing is
|
|
* a separate question and still answers MEDIA3 — nothing about a dropped track makes the job
|
|
* un-hardware-able — so a request that skips validation, from a direct
|
|
* `ConversionWorker.request(...)` or a job queued before the settings changed, arrives at the
|
|
* engine with a plan Media3 cannot build. That has to fail the job, not the process.
|
|
*/
|
|
@Test
|
|
fun `a plan that drops both tracks still routes to media3`() {
|
|
val audioOnly = InputProbe(
|
|
videoCodec = null,
|
|
audioCodec = "mp3",
|
|
hasVideo = false,
|
|
container = Container.MP3,
|
|
kind = InputKind.AUDIO_ONLY,
|
|
)
|
|
val spec = OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE)
|
|
|
|
val plan = CopyPlanner.plan(spec, audioOnly)
|
|
assertEquals(VideoPlan.Drop, plan.video)
|
|
assertEquals(AudioPlan.Drop, plan.audio)
|
|
|
|
assertEquals(Engine.MEDIA3, route(spec, probe = audioOnly).engine)
|
|
}
|
|
|
|
@Test
|
|
fun `audio-only formats are flagged as such`() {
|
|
assertEquals(true, OutputFormat.MP3.isAudioOnly)
|
|
assertEquals(true, OutputFormat.FLAC.isAudioOnly)
|
|
assertEquals(false, OutputFormat.MP4_H264.isAudioOnly)
|
|
assertEquals(false, OutputFormat.GIF.isAudioOnly)
|
|
}
|
|
}
|