Done now rather than later: the application ID is permanent once published -- Play treats a change as an entirely different app -- so this is the last cheap moment to choose it. applicationId / namespace dev.jasonmross.mediaconverter -> org.libremediaconverter source tree java/dev/jasonmross/mediaconverter -> java/org/libremediaconverter gradle project AndroidMediaConverter -> LibreMediaConverter theme Theme.MediaConverter -> Theme.LibreMediaConverter compose theme MediaConverterTheme -> LibreMediaConverterTheme display name "Media Converter" -> "LibreMediaConverter" org.* rather than dev.jasonmross.* because "Libre" signals a project rather than a personal app, and a project-owned namespace lets maintainership move later without the identifier contradicting reality. The source trees moved with git mv so history follows the files instead of showing 42 deletions beside 42 additions. Verified after the rename: 66 unit tests, and 40 instrumented tests on an API 36 emulator, 0 failures. The built APK reports org.libremediaconverter, and no stale jasonmross, AndroidMediaConverter or MediaConverterTheme identifiers remain anywhere in the tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
198 lines
7.2 KiB
Kotlin
198 lines
7.2 KiB
Kotlin
package org.libremediaconverter.model
|
|
|
|
import org.libremediaconverter.model.ConversionRouter.Reason
|
|
import org.junit.Assert.assertEquals
|
|
import org.junit.Test
|
|
|
|
/**
|
|
* 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, 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 because media3 cannot encode vp9`() {
|
|
// Transformer.setVideoMimeType accepts only H.263/H.264/H.265/MP4V. The WebM
|
|
// muxer exists, but there is no VP9 *encoder* behind it, so producing WebM
|
|
// video is FFmpeg's job even though the container is nominally supported.
|
|
val d = route(OutputFormat.WEBM_VP9)
|
|
assertEquals(Engine.FFMPEG, d.engine)
|
|
assertEquals(Reason.NO_PLATFORM_ENCODER, 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)
|
|
}
|
|
|
|
// --- 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,
|
|
)
|
|
}
|
|
}
|
|
|
|
@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)
|
|
}
|
|
}
|