Rename the project to LibreMediaConverter
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>
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
package org.libremediaconverter.ffmpeg
|
||||
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FFmpegCommandBuilderTest {
|
||||
|
||||
private fun cmd(
|
||||
format: OutputFormat,
|
||||
quality: QualityTier = QualityTier.BEST,
|
||||
hardwareEncodeAvailable: Boolean = true,
|
||||
): List<String> = FFmpegCommandBuilder.build(
|
||||
ConversionRequest(
|
||||
format = format,
|
||||
quality = quality,
|
||||
hardwareEncodeAvailable = hardwareEncodeAvailable,
|
||||
),
|
||||
inputPath = "/cache/in.mp4",
|
||||
outputPath = "/cache/out.${format.extension}",
|
||||
)
|
||||
|
||||
/** Asserts `flag` is present and immediately followed by `value`. */
|
||||
private fun assertPair(args: List<String>, flag: String, value: String) {
|
||||
val i = args.indexOf(flag)
|
||||
assertTrue("missing $flag in $args", i >= 0)
|
||||
assertEquals("wrong value for $flag in $args", value, args[i + 1])
|
||||
}
|
||||
|
||||
// --- structure ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `input precedes output and both are present`() {
|
||||
val args = cmd(OutputFormat.MP4_H264)
|
||||
assertPair(args, "-i", "/cache/in.mp4")
|
||||
assertEquals("/cache/out.mp4", args.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overwrite is enabled because the output path is our own cache file`() {
|
||||
assertTrue(cmd(OutputFormat.MP4_H264).contains("-y"))
|
||||
}
|
||||
|
||||
// --- the GPL quality path ---------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `best quality h264 uses libx264 with crf, not a bitrate target`() {
|
||||
val args = cmd(OutputFormat.MP4_H264, QualityTier.BEST)
|
||||
assertPair(args, "-c:v", "libx264")
|
||||
assertPair(args, "-crf", "20")
|
||||
assertFalse("CRF and -b:v are mutually exclusive", args.contains("-b:v"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `best quality h265 uses libx265 with a higher crf than h264`() {
|
||||
val args = cmd(OutputFormat.MP4_H265, QualityTier.BEST)
|
||||
assertPair(args, "-c:v", "libx265")
|
||||
// x265 is roughly a step stronger at the same number, so sharing a constant
|
||||
// with x264 would silently change the size target.
|
||||
assertPair(args, "-crf", "24")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hevc in mp4 is tagged hvc1 for player compatibility`() {
|
||||
assertPair(cmd(OutputFormat.MP4_H265, QualityTier.BEST), "-tag:v", "hvc1")
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for a failure found with real footage on a Pixel 10 Pro XL.
|
||||
*
|
||||
* The source was H.264 High 4:4:4 Predictive. FFmpeg decodes that to yuv444p and,
|
||||
* without an explicit pixel format, hands those frames straight to an encoder that
|
||||
* cannot accept them — hevc_mediacodec died with "Invalid to call at Released
|
||||
* state" partway through. Every video encode path has to name the format so FFmpeg
|
||||
* inserts the conversion, not just the one path that happened to have it.
|
||||
*/
|
||||
@Test
|
||||
fun `every video encode path forces yuv420p`() {
|
||||
val videoFormats = listOf(
|
||||
OutputFormat.MP4_H264, OutputFormat.MP4_H265,
|
||||
OutputFormat.MKV_H264, OutputFormat.MKV_H265,
|
||||
OutputFormat.WEBM_VP9,
|
||||
)
|
||||
videoFormats.forEach { format ->
|
||||
listOf(QualityTier.FAST, QualityTier.BEST).forEach { quality ->
|
||||
listOf(true, false).forEach { hw ->
|
||||
val args = cmd(format, quality, hardwareEncodeAvailable = hw)
|
||||
assertPair(args, "-pix_fmt", "yuv420p")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio only outputs do not set a pixel format`() {
|
||||
listOf(OutputFormat.MP3, OutputFormat.FLAC, OutputFormat.WAV).forEach {
|
||||
assertFalse("${it.name} should not set -pix_fmt", cmd(it).contains("-pix_fmt"))
|
||||
}
|
||||
}
|
||||
|
||||
// --- the hardware fallback path ---------------------------------------
|
||||
|
||||
/**
|
||||
* Regression test for a defect found on a device with no hardware HEVC encoder.
|
||||
*
|
||||
* FFmpeg's *_mediacodec wrappers do not fail on such a device — they quietly bind
|
||||
* to the platform software codec (c2.android.*) and encode far slower than
|
||||
* libx264/libx265 would, while still presenting as the fast path. When no hardware
|
||||
* encoder exists, a real software encoder on a fast preset is both quicker and
|
||||
* honest about what it is doing.
|
||||
*/
|
||||
@Test
|
||||
fun `the encoder choice no longer depends on hardware availability`() {
|
||||
// Once FFmpeg stopped selecting MediaCodec encoders, this flag only affects
|
||||
// whether the router sends the job to Media3 at all -- not what FFmpeg does.
|
||||
listOf(OutputFormat.MP4_H264, OutputFormat.MP4_H265).forEach { format ->
|
||||
assertEquals(
|
||||
cmd(format, QualityTier.FAST, hardwareEncodeAvailable = true),
|
||||
cmd(format, QualityTier.FAST, hardwareEncodeAvailable = false),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fast software preset is faster than the best quality preset`() {
|
||||
// Both use libx264; the distinction between the tiers has to survive the
|
||||
// fallback, otherwise Fast and Best become the same slow thing.
|
||||
val fast = cmd(OutputFormat.MP4_H264, QualityTier.FAST, hardwareEncodeAvailable = false)
|
||||
val best = cmd(OutputFormat.MP4_H264, QualityTier.BEST)
|
||||
assertEquals("veryfast", fast[fast.indexOf("-preset") + 1])
|
||||
assertEquals("medium", best[best.indexOf("-preset") + 1])
|
||||
}
|
||||
|
||||
// --- audio -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `mp3 uses libmp3lame and drops video`() {
|
||||
val args = cmd(OutputFormat.MP3)
|
||||
assertPair(args, "-c:a", "libmp3lame")
|
||||
assertTrue("audio-only output must drop the video stream", args.contains("-vn"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flac wav and opus select the right encoders`() {
|
||||
assertPair(cmd(OutputFormat.FLAC), "-c:a", "flac")
|
||||
assertPair(cmd(OutputFormat.WAV), "-c:a", "pcm_s16le")
|
||||
assertPair(cmd(OutputFormat.OPUS), "-c:a", "libopus")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio only formats never carry a video encoder`() {
|
||||
listOf(OutputFormat.MP3, OutputFormat.FLAC, OutputFormat.WAV, OutputFormat.OPUS)
|
||||
.forEach { format ->
|
||||
val args = cmd(format)
|
||||
assertFalse("$format should not set -c:v", args.contains("-c:v"))
|
||||
assertTrue("$format should set -vn", args.contains("-vn"))
|
||||
}
|
||||
}
|
||||
|
||||
// --- image outputs -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `gif generates a palette to avoid banding and drops audio`() {
|
||||
val args = cmd(OutputFormat.GIF)
|
||||
val filter = args[args.indexOf("-vf") + 1]
|
||||
assertTrue("gif needs palettegen: $filter", filter.contains("palettegen"))
|
||||
assertTrue("gif needs paletteuse: $filter", filter.contains("paletteuse"))
|
||||
assertTrue(args.contains("-an"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gif loops forever`() {
|
||||
assertPair(cmd(OutputFormat.GIF), "-loop", "0")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `frame export sets an fps filter and numbered output`() {
|
||||
val args = cmd(OutputFormat.FRAMES_PNG)
|
||||
assertPair(args, "-vf", "fps=1")
|
||||
assertEquals(
|
||||
"shot_%04d.png",
|
||||
FFmpegCommandBuilder.outputPattern(OutputFormat.FRAMES_PNG, "shot"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single file formats keep their plain name`() {
|
||||
assertEquals(
|
||||
"clip.mp4",
|
||||
FFmpegCommandBuilder.outputPattern(OutputFormat.MP4_H264, "clip.mp4"),
|
||||
)
|
||||
}
|
||||
|
||||
// --- containers --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `mp4 output enables faststart`() {
|
||||
assertPair(cmd(OutputFormat.MP4_H264), "-movflags", "+faststart")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non mp4 containers do not get faststart`() {
|
||||
assertFalse(cmd(OutputFormat.MKV_H264).contains("-movflags"))
|
||||
assertFalse(cmd(OutputFormat.WAV).contains("-movflags"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mkv accepts h264 and h265 without faststart`() {
|
||||
assertPair(cmd(OutputFormat.MKV_H264, QualityTier.BEST), "-c:v", "libx264")
|
||||
assertPair(cmd(OutputFormat.MKV_H265, QualityTier.BEST), "-c:v", "libx265")
|
||||
}
|
||||
|
||||
// --- exhaustiveness ----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `every format builds a well formed command`() {
|
||||
OutputFormat.entries.forEach { format ->
|
||||
listOf(QualityTier.FAST, QualityTier.BEST).forEach { quality ->
|
||||
val args = cmd(format, quality)
|
||||
assertTrue("$format/$quality has no input", args.contains("-i"))
|
||||
assertTrue("$format/$quality has too few args", args.size >= 5)
|
||||
// Every flag that takes a value must actually have one.
|
||||
assertFalse("$format/$quality ends on a dangling flag", args.last().startsWith("-"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package org.libremediaconverter.ffmpeg
|
||||
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class FFmpegConcatCommandTest {
|
||||
|
||||
private val listFile = File("/cache/list.txt")
|
||||
private val output = File("/cache/joined.mp4")
|
||||
private val inputs = listOf("/cache/a.mp4", "/cache/b.mp4")
|
||||
|
||||
@Test
|
||||
fun `list file quotes each entry for the concat demuxer`() {
|
||||
val text = FFmpegConcatCommand.listFileContents(inputs)
|
||||
assertEquals("file '/cache/a.mp4'\nfile '/cache/b.mp4'\n", text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apostrophes in filenames are escaped`() {
|
||||
// An unescaped apostrophe silently truncates the entry, so the file would be
|
||||
// skipped rather than the command failing.
|
||||
val text = FFmpegConcatCommand.listFileContents(listOf("/cache/jason's clip.mp4"))
|
||||
assertTrue("apostrophe not escaped: $text", text.contains("""jason'\''s clip.mp4"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stream copy uses the concat demuxer and copies codecs`() {
|
||||
val args = FFmpegConcatCommand.build(
|
||||
ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264,
|
||||
)
|
||||
assertTrue(args.contains("concat"))
|
||||
assertEquals("copy", args[args.indexOf("-c") + 1])
|
||||
assertEquals(listFile.absolutePath, args[args.indexOf("-i") + 1])
|
||||
assertFalse("stream copy must not re-encode", args.contains("libx264"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stream copy allows absolute paths in the list file`() {
|
||||
val args = FFmpegConcatCommand.build(
|
||||
ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264,
|
||||
)
|
||||
// Without -safe 0 the demuxer rejects the absolute paths we generate.
|
||||
assertEquals("0", args[args.indexOf("-safe") + 1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-encode passes every input separately and builds a filter graph`() {
|
||||
val args = FFmpegConcatCommand.build(
|
||||
ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264,
|
||||
)
|
||||
assertEquals(2, args.count { it == "-i" })
|
||||
val filter = args[args.indexOf("-filter_complex") + 1]
|
||||
assertTrue("missing concat filter: $filter", filter.contains("concat=n=2:v=1:a=1"))
|
||||
assertTrue("inputs must be normalised before joining", filter.contains("scale="))
|
||||
assertTrue("frame rates must be normalised", filter.contains("fps=30"))
|
||||
assertTrue(args.contains("libx264"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-encode maps the filter outputs rather than raw streams`() {
|
||||
val args = FFmpegConcatCommand.build(
|
||||
ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264,
|
||||
)
|
||||
assertTrue(args.contains("[v]"))
|
||||
assertTrue(args.contains("[a]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mp4 output gets faststart on both strategies`() {
|
||||
listOf(ConcatStrategy.STREAM_COPY, ConcatStrategy.REENCODE).forEach { strategy ->
|
||||
val args = FFmpegConcatCommand.build(strategy, inputs, listFile, output, OutputFormat.MP4_H264)
|
||||
assertTrue("$strategy missing faststart", args.contains("+faststart"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mkv output does not get faststart`() {
|
||||
val args = FFmpegConcatCommand.build(
|
||||
ConcatStrategy.STREAM_COPY, inputs, listFile, File("/cache/j.mkv"), OutputFormat.MKV_H264,
|
||||
)
|
||||
assertFalse(args.contains("+faststart"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `output path is always last`() {
|
||||
listOf(ConcatStrategy.STREAM_COPY, ConcatStrategy.REENCODE).forEach { strategy ->
|
||||
val args = FFmpegConcatCommand.build(strategy, inputs, listFile, output, OutputFormat.MP4_H264)
|
||||
assertEquals(output.absolutePath, args.last())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package org.libremediaconverter.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The stream-copy-vs-re-encode decision.
|
||||
*
|
||||
* This matters more than it looks: the `concat` demuxer does not reject mismatched
|
||||
* inputs loudly, it can emit a file whose later segments are garbled. So the default
|
||||
* on any doubt has to be re-encoding.
|
||||
*/
|
||||
class ConcatPlannerTest {
|
||||
|
||||
private fun clip(
|
||||
video: String? = "h264",
|
||||
audio: String? = "aac",
|
||||
width: Int = 1920,
|
||||
height: Int = 1080,
|
||||
fps: Int = 30,
|
||||
) = ConcatInput(video, audio, width, height, fps)
|
||||
|
||||
@Test
|
||||
fun `identical clips can be stream copied`() {
|
||||
assertEquals(
|
||||
ConcatStrategy.STREAM_COPY,
|
||||
ConcatPlanner.plan(listOf(clip(), clip())),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single input needs no re-encode`() {
|
||||
assertEquals(ConcatStrategy.STREAM_COPY, ConcatPlanner.plan(listOf(clip())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different video codecs force a re-encode`() {
|
||||
assertEquals(
|
||||
ConcatStrategy.REENCODE,
|
||||
ConcatPlanner.plan(listOf(clip(video = "h264"), clip(video = "hevc"))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different audio codecs force a re-encode`() {
|
||||
assertEquals(
|
||||
ConcatStrategy.REENCODE,
|
||||
ConcatPlanner.plan(listOf(clip(audio = "aac"), clip(audio = "opus"))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different resolutions force a re-encode`() {
|
||||
assertEquals(
|
||||
ConcatStrategy.REENCODE,
|
||||
ConcatPlanner.plan(listOf(clip(width = 1920, height = 1080), clip(width = 1280, height = 720))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different frame rates force a re-encode`() {
|
||||
assertEquals(
|
||||
ConcatStrategy.REENCODE,
|
||||
ConcatPlanner.plan(listOf(clip(fps = 30), clip(fps = 60))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown codec is not treated as a match`() {
|
||||
// Two nulls are not evidence of agreement. Assuming they match is exactly how
|
||||
// a silent corrupt concat happens.
|
||||
assertEquals(
|
||||
ConcatStrategy.REENCODE,
|
||||
ConcatPlanner.plan(listOf(clip(video = null), clip(video = null))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a mismatch anywhere in a longer list is caught`() {
|
||||
val clips = listOf(clip(), clip(), clip(), clip(width = 640, height = 480), clip())
|
||||
assertEquals(ConcatStrategy.REENCODE, ConcatPlanner.plan(clips))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package org.libremediaconverter.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Guards the format table itself.
|
||||
*
|
||||
* The router, the FFmpeg command builder and the output filename all read from these
|
||||
* entries, so a wrong extension or a mislabelled codec propagates everywhere.
|
||||
*/
|
||||
class OutputFormatTest {
|
||||
|
||||
@Test
|
||||
fun `every format has a non-empty label and extension`() {
|
||||
OutputFormat.entries.forEach {
|
||||
assertTrue("${it.name} has no label", it.label.isNotBlank())
|
||||
assertTrue("${it.name} has no extension", it.extension.isNotBlank())
|
||||
assertFalse("${it.name} extension should not include a dot", it.extension.startsWith("."))
|
||||
assertTrue("${it.name} has no mime type", it.mimeType.contains('/'))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio only formats carry no video codec`() {
|
||||
OutputFormat.entries.filter { it.isAudioOnly }.forEach {
|
||||
assertEquals("${it.name} should have no video codec", VideoCodec.NONE, it.videoCodec)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `image outputs carry neither video nor audio codecs`() {
|
||||
OutputFormat.entries.filter { it.isImageOutput }.forEach {
|
||||
assertEquals(VideoCodec.NONE, it.videoCodec)
|
||||
assertEquals(AudioCodec.NONE, it.audioCodec)
|
||||
assertFalse("${it.name} is not audio-only", it.isAudioOnly)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extensions match their containers`() {
|
||||
assertEquals("mp4", OutputFormat.MP4_H264.extension)
|
||||
assertEquals("mkv", OutputFormat.MKV_H264.extension)
|
||||
assertEquals("mp3", OutputFormat.MP3.extension)
|
||||
assertEquals("gif", OutputFormat.GIF.extension)
|
||||
assertEquals("wav", OutputFormat.WAV.extension)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `video formats are not misreported as audio only`() {
|
||||
listOf(OutputFormat.MP4_H264, OutputFormat.MP4_H265, OutputFormat.MKV_H265, OutputFormat.WEBM_VP9)
|
||||
.forEach { assertFalse("${it.name} should not be audio-only", it.isAudioOnly) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quality tiers describe themselves for the UI`() {
|
||||
QualityTier.entries.forEach {
|
||||
assertTrue(it.label.isNotBlank())
|
||||
assertTrue(it.description.isNotBlank())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import androidx.work.WorkInfo
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The retry-versus-fail rule.
|
||||
*
|
||||
* Isolated from the worker precisely so it can be tested: the condition that triggers
|
||||
* a retry in production is the foreground-service budget running out, six hours per
|
||||
* twenty-four, which no test can reach. Extracting the decision means the rule is still
|
||||
* verified even though its trigger cannot be reproduced.
|
||||
*/
|
||||
class FailureOutcomeTest {
|
||||
|
||||
@Test
|
||||
fun `a foreground service timeout is a retry, not a failure`() {
|
||||
// The work is still valid; there is simply no budget right now. Telling the
|
||||
// user their conversion failed would be wrong.
|
||||
assertEquals(
|
||||
FailureOutcome.RETRY,
|
||||
FailureOutcome.forStopReason(WorkInfo.STOP_REASON_FOREGROUND_SERVICE_TIMEOUT),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary failure is reported as a failure`() {
|
||||
assertEquals(
|
||||
FailureOutcome.FAIL,
|
||||
FailureOutcome.forStopReason(WorkInfo.STOP_REASON_NOT_STOPPED),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every other stop reason fails rather than retrying forever`() {
|
||||
// Retrying on, say, a user cancellation or a battery constraint would either
|
||||
// ignore the user or spin. Only the timeout earns a retry.
|
||||
val others = listOf(
|
||||
WorkInfo.STOP_REASON_CANCELLED_BY_APP,
|
||||
WorkInfo.STOP_REASON_USER,
|
||||
WorkInfo.STOP_REASON_CONSTRAINT_BATTERY_NOT_LOW,
|
||||
WorkInfo.STOP_REASON_CONSTRAINT_CHARGING,
|
||||
WorkInfo.STOP_REASON_CONSTRAINT_CONNECTIVITY,
|
||||
WorkInfo.STOP_REASON_CONSTRAINT_DEVICE_IDLE,
|
||||
WorkInfo.STOP_REASON_CONSTRAINT_STORAGE_NOT_LOW,
|
||||
WorkInfo.STOP_REASON_DEVICE_STATE,
|
||||
WorkInfo.STOP_REASON_QUOTA,
|
||||
WorkInfo.STOP_REASON_BACKGROUND_RESTRICTION,
|
||||
WorkInfo.STOP_REASON_APP_STANDBY,
|
||||
WorkInfo.STOP_REASON_TIMEOUT,
|
||||
WorkInfo.STOP_REASON_UNKNOWN,
|
||||
)
|
||||
others.forEach {
|
||||
assertEquals("stop reason $it should fail", FailureOutcome.FAIL, FailureOutcome.forStopReason(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user