Let the user pick a container and codecs independently, and remux without re-encoding
OutputFormat was a closed enum of twelve (container, videoCodec, audioCodec) triples, defended on the grounds that a closed set was what made routing decidable. Two things it could not express: changing the container while copying the streams, and choosing codecs per track. OutputSpec replaces it as the vocabulary; OutputFormat stays as presets over it. Decidability moves to ContainerCapabilities, which is explicit and unit-tested rather than implicit in whichever combinations somebody enumerated. The matrix is indexed by (container, codec, trackType, mode), not one boolean. "Can MP4 carry AV1" and "can this app encode AV1" have different answers, and copy is where the difference shows: a single flag would refuse a legitimate remux or promise an encode neither engine can deliver. COPY is a codec value rather than a flag, so every exhaustive `when` in the codebase had to say what it does about copying. CopyPlanner resolves it before anything else reads the request, and inherits ConcatPlanner's rule that an unproven match is never a copy — a needless re-encode costs time, a wrong stream copy costs a file that will not play. Container now drives -f, the extension and the SAF MIME type, so Matroska without video is .mka and MP4 without video is .m4a without a preset for each. FLAC was declared as Container.MKV with a .flac extension, inert only while nothing read the container; it now has its own. Six containers added: MOV, MKV audio, MPEG-TS, AVI, FLV and WMV/ASF. Routing asks the plan, never the request. COPY belongs to none of the capability sets, so testing the request directly sends every remux to FFmpeg on the first check — and nothing notices, because -c copy produces a correct file, just on the CPU. The router also learns what Media3 can *carry* as opposed to encode: its MP4 muxer takes AAC, Opus, Vorbis and PCM but neither MP3 nor FLAC. MediaProbe now separates "no video track" from "could not parse" and reports the source container, which MediaExtractor cannot supply at all. FFprobe runs on every pick for that reason, not as a fallback. The Advanced picker shows the whole matrix and lets an impossible combination be selected on purpose, then explains it and offers alternatives. Convert is what blocks the job. ConversionWorker validates too, so a stale queued spec fails with the reason rather than being coerced into something else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -100,7 +100,7 @@ class RealMediaBenchmark {
|
||||
val hwOut = File(context.cacheDir, "bench_hw.mp4").apply { delete() }
|
||||
val engine = Media3Engine(context)
|
||||
val hwMs = try {
|
||||
runCatching { timed { engine.transcode(uri, hwOut, OutputFormat.MP4_H265) } }
|
||||
runCatching { timed { engine.transcode(uri, hwOut, ConversionRequest(OutputFormat.MP4_H265.spec)) } }
|
||||
.onFailure { Log.w(TAG, "BENCH hardware: UNSUPPORTED (${it.message})") }
|
||||
.getOrNull()
|
||||
} finally {
|
||||
@@ -112,7 +112,7 @@ class RealMediaBenchmark {
|
||||
val swMs = timed {
|
||||
FFmpegEngine().run(
|
||||
request = ConversionRequest(
|
||||
format = OutputFormat.MP4_H264,
|
||||
spec = OutputFormat.MP4_H264.spec,
|
||||
quality = QualityTier.BEST,
|
||||
),
|
||||
inputPath = input.absolutePath,
|
||||
@@ -153,7 +153,7 @@ class RealMediaBenchmark {
|
||||
|
||||
val probe = MediaProbe.probe(context, Uri.fromFile(input))
|
||||
val decision = ConversionRouter.route(
|
||||
ConversionRequest(format = OutputFormat.MP4_H265, probe = probe),
|
||||
ConversionRequest(spec = OutputFormat.MP4_H265.spec, probe = probe),
|
||||
AndroidDeviceCodecs.get(),
|
||||
)
|
||||
Log.i(TAG, "BENCH av1 probe: codec=${probe.videoCodec} duration=${probe.durationMs}ms")
|
||||
@@ -162,7 +162,7 @@ class RealMediaBenchmark {
|
||||
val out = File(context.cacheDir, "bench_av1_out.mp4").apply { delete() }
|
||||
val engine = Media3Engine(context)
|
||||
val ms = try {
|
||||
timed { engine.transcode(Uri.fromFile(input), out, OutputFormat.MP4_H265) }
|
||||
timed { engine.transcode(Uri.fromFile(input), out, ConversionRequest(OutputFormat.MP4_H265.spec)) }
|
||||
} finally {
|
||||
engine.close()
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
@@ -62,7 +63,7 @@ class Media3EngineTest {
|
||||
engine.transcode(
|
||||
input = Uri.fromFile(input),
|
||||
output = output,
|
||||
format = OutputFormat.MP4_H265,
|
||||
request = ConversionRequest(OutputFormat.MP4_H265.spec),
|
||||
) { percent -> seen += percent }
|
||||
|
||||
assertTrue("export produced no file", output.exists())
|
||||
@@ -95,7 +96,7 @@ class Media3EngineTest {
|
||||
val audio = File(context.cacheDir, "out_audio.m4a")
|
||||
audio.delete()
|
||||
try {
|
||||
engine.transcode(Uri.fromFile(input), audio, OutputFormat.M4A_AAC)
|
||||
engine.transcode(Uri.fromFile(input), audio, ConversionRequest(OutputFormat.M4A_AAC.spec))
|
||||
|
||||
assertTrue("export produced no file", audio.exists() && audio.length() > 0)
|
||||
val tracks = trackMimeTypesOf(audio)
|
||||
@@ -121,7 +122,7 @@ class Media3EngineTest {
|
||||
val wav = File(context.cacheDir, "out_audio.wav")
|
||||
wav.delete()
|
||||
try {
|
||||
engine.transcode(Uri.fromFile(input), wav, OutputFormat.WAV)
|
||||
engine.transcode(Uri.fromFile(input), wav, ConversionRequest(OutputFormat.WAV.spec))
|
||||
|
||||
assertTrue("export produced no file", wav.exists() && wav.length() > 0)
|
||||
assertEquals("RIFF", String(wav.readBytes().copyOfRange(0, 4), Charsets.US_ASCII))
|
||||
@@ -141,7 +142,7 @@ class Media3EngineTest {
|
||||
val ogg = File(context.cacheDir, "out_audio.opus")
|
||||
ogg.delete()
|
||||
try {
|
||||
engine.transcode(Uri.fromFile(input), ogg, OutputFormat.OPUS)
|
||||
engine.transcode(Uri.fromFile(input), ogg, ConversionRequest(OutputFormat.OPUS.spec))
|
||||
|
||||
assertTrue("export produced no file", ogg.exists() && ogg.length() > 0)
|
||||
assertEquals("OggS", String(ogg.readBytes().copyOfRange(0, 4), Charsets.US_ASCII))
|
||||
@@ -197,7 +198,7 @@ class Media3EngineTest {
|
||||
val failure = runCatching {
|
||||
runBlocking {
|
||||
withTimeout(30_000) {
|
||||
engine.transcode(Uri.fromFile(input), impossible, OutputFormat.MP4_H265) {}
|
||||
engine.transcode(Uri.fromFile(input), impossible, ConversionRequest(OutputFormat.MP4_H265.spec)) {}
|
||||
}
|
||||
}
|
||||
}.exceptionOrNull()
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.Engine
|
||||
import org.libremediaconverter.model.InputKind
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Remuxing end to end, on a device.
|
||||
*
|
||||
* ## Why these assert the engine, not just the file
|
||||
*
|
||||
* A remux routed to FFmpeg produces a perfectly correct file — `-c copy` moves the same samples
|
||||
* into the same container. So an output-only assertion passes whether the hardware transmux path
|
||||
* ran or never executed at all, and `COPY` belongs to none of the router's capability sets, which
|
||||
* makes "silently always FFmpeg" the most likely way for this feature to regress.
|
||||
*
|
||||
* Timing is not a usable proxy either: it is flaky on emulators and cannot distinguish an FFmpeg
|
||||
* stream copy from a Media3 transmux, since both are fast. `ConversionWorker` already reports
|
||||
* `KEY_ENGINE_USED`, so these assert that.
|
||||
*
|
||||
* ## Fixtures
|
||||
*
|
||||
* Every committed asset was ISO MP4 before this feature, so a remux had no second container to
|
||||
* move between. The new ones are generated rather than sourced, and committed rather than built at
|
||||
* test time, following the convention `HardwareFallbackTest` documents — a regression test that
|
||||
* silently skips is worse than no test. Recipes, run against FFmpeg 8.1.2:
|
||||
*
|
||||
* ```
|
||||
* # sample_h264.mkv — the existing fixture's streams in Matroska. A pure remux, so the two files
|
||||
* # hold byte-identical samples and differ only in the container, which is exactly the axis under
|
||||
* # test. No encoder is involved, so the recipe reproduces anywhere.
|
||||
* ffmpeg -i sample_h264.mp4 -c copy -f matroska sample_h264.mkv
|
||||
*
|
||||
* # sample_aac.m4a — the same fixture's audio track alone, for AUDIO_ONLY probing and MKA output.
|
||||
* ffmpeg -i sample_h264.mp4 -vn -c:a copy -f mp4 sample_aac.m4a
|
||||
*
|
||||
* # sample_vp9.webm — the one fixture that must be encoded, since no committed source is VP9.
|
||||
* ffmpeg -i sample_h264.mp4 -c:v libvpx-vp9 -crf 40 -b:v 0 -deadline realtime -cpu-used 8 \
|
||||
* -c:a libopus -b:a 64k -f webm sample_vp9.webm
|
||||
*
|
||||
* # sample_still.png — a single frame, to reach the probe's IMAGE branch.
|
||||
* ffmpeg -i sample_h264.mp4 -frames:v 1 -f image2 sample_still.png
|
||||
* ```
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RemuxTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val staged = mutableListOf<File>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
WorkManager.getInstance(context).cancelAllWork()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
staged.forEach { it.delete() }
|
||||
WorkManager.getInstance(context).cancelAllWork()
|
||||
}
|
||||
|
||||
private fun asset(name: String): File =
|
||||
File(context.cacheDir, name).also { file ->
|
||||
file.delete()
|
||||
staged += file
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open(name)
|
||||
.use { asset -> file.outputStream().use { asset.copyTo(it) } }
|
||||
}
|
||||
|
||||
// --- probing ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun probeIdentifiesTheSourceContainerOfEachFixture() {
|
||||
assertEquals(Container.MP4, MediaProbe.probe(context, Uri.fromFile(asset("sample_h264.mp4"))).container)
|
||||
assertEquals(Container.MKV, MediaProbe.probe(context, Uri.fromFile(asset("sample_h264.mkv"))).container)
|
||||
assertEquals(Container.WEBM, MediaProbe.probe(context, Uri.fromFile(asset("sample_vp9.webm"))).container)
|
||||
}
|
||||
|
||||
/**
|
||||
* The distinction the old probe could not make.
|
||||
*
|
||||
* It reported `hasVideo = true, videoCodec = UNPARSEABLE` for anything the extractor refused,
|
||||
* so an audio file, a picture and a corrupt file were indistinguishable — and the source-info
|
||||
* card cannot describe any of them honestly until they are separate.
|
||||
*/
|
||||
@Test
|
||||
fun probeDistinguishesAudioFromImagesFromRubbish() {
|
||||
val audio = MediaProbe.probe(context, Uri.fromFile(asset("sample_aac.m4a")))
|
||||
assertEquals(InputKind.AUDIO_ONLY, audio.kind)
|
||||
assertEquals(false, audio.hasVideo)
|
||||
assertNotNull("audio codec should still be identified", audio.audioCodec)
|
||||
|
||||
val image = MediaProbe.probe(context, Uri.fromFile(asset("sample_still.png")))
|
||||
assertEquals(InputKind.IMAGE, image.kind)
|
||||
|
||||
val rubbish = File(context.cacheDir, "not_media.bin").also {
|
||||
staged += it
|
||||
it.writeBytes(ByteArray(4096) { i -> (i % 251).toByte() })
|
||||
}
|
||||
val unreadable = MediaProbe.probe(context, Uri.fromFile(rubbish))
|
||||
assertEquals(InputKind.UNPARSEABLE, unreadable.kind)
|
||||
}
|
||||
|
||||
// --- the hardware remux path --------------------------------------------
|
||||
|
||||
/**
|
||||
* MKV in, MP4 out, nothing re-encoded — and it must stay on Media3.
|
||||
*
|
||||
* Media3 cannot *write* Matroska but reads it perfectly well, which is what makes this
|
||||
* direction a hardware remux. If this reports FFMPEG, the transmux path is dead.
|
||||
*/
|
||||
@Test
|
||||
fun mkvToMp4RemuxesOnHardware() {
|
||||
val input = asset("sample_h264.mkv")
|
||||
val result = runConversion(
|
||||
input,
|
||||
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY),
|
||||
)
|
||||
|
||||
assertEquals(Engine.MEDIA3.name, result.engineUsed)
|
||||
assertEquals("h264 must survive a copy untouched", "video/avc", videoMimeOf(result.output))
|
||||
assertTrue("output has no duration", durationMsOf(result.output) > 0)
|
||||
}
|
||||
|
||||
/** The reverse direction: Media3 has no Matroska muxer, so this is FFmpeg's. */
|
||||
@Test
|
||||
fun mp4ToMkvRemuxesOnFFmpeg() {
|
||||
val input = asset("sample_h264.mp4")
|
||||
val result = runConversion(
|
||||
input,
|
||||
OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY),
|
||||
)
|
||||
|
||||
assertEquals(Engine.FFMPEG.name, result.engineUsed)
|
||||
assertEquals(
|
||||
"expected an EBML header",
|
||||
listOf(0x1A, 0x45, 0xDF, 0xA3),
|
||||
result.output.readBytes().take(4).map { it.toInt() and 0xFF },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun webmToMkvKeepsVp9WithoutReencoding() {
|
||||
val input = asset("sample_vp9.webm")
|
||||
val result = runConversion(
|
||||
input,
|
||||
OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY),
|
||||
)
|
||||
|
||||
assertTrue(result.output.length() > 0)
|
||||
assertEquals("vp9 must survive a copy", "video/x-vnd.on2.vp9", videoMimeOf(result.output))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioOnlySourceRemuxesIntoMka() {
|
||||
val input = asset("sample_aac.m4a")
|
||||
val spec = OutputSpec(Container.MKV, VideoCodec.NONE, AudioCodec.COPY)
|
||||
val result = runConversion(input, spec)
|
||||
|
||||
assertEquals("mka", spec.extension)
|
||||
assertTrue(result.output.length() > 0)
|
||||
val tracks = trackMimesOf(result.output)
|
||||
assertTrue("expected audio only, got $tracks", tracks.none { it.startsWith("video/") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mp4ToMpegTsAndAviProduceTheirOwnContainers() {
|
||||
val input = asset("sample_h264.mp4")
|
||||
|
||||
val ts = runConversion(input, OutputSpec(Container.MPEG_TS, VideoCodec.COPY, AudioCodec.COPY))
|
||||
assertEquals(Engine.FFMPEG.name, ts.engineUsed)
|
||||
// Every MPEG-TS packet starts with the 0x47 sync byte.
|
||||
assertEquals(0x47, ts.output.readBytes().first().toInt() and 0xFF)
|
||||
|
||||
val avi = runConversion(input, OutputSpec(Container.AVI, VideoCodec.COPY, AudioCodec.COPY))
|
||||
assertEquals("RIFF", String(avi.output.readBytes().copyOfRange(0, 4), Charsets.US_ASCII))
|
||||
}
|
||||
|
||||
// --- plumbing -----------------------------------------------------------
|
||||
|
||||
private class Result(val output: File, val engineUsed: String)
|
||||
|
||||
/**
|
||||
* Runs one conversion through the real worker.
|
||||
*
|
||||
* Deliberately through `ConversionWorker` rather than an engine directly: the routing decision
|
||||
* is the thing under test, and only the worker makes it.
|
||||
*/
|
||||
private fun runConversion(input: File, spec: OutputSpec): Result = runBlocking {
|
||||
val manager = WorkManager.getInstance(context)
|
||||
val request = ConversionWorker.request(
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = input.name,
|
||||
sizeBytes = input.length(),
|
||||
spec = spec,
|
||||
)
|
||||
manager.enqueue(request).result.get()
|
||||
|
||||
val terminal = withTimeout(TIMEOUT_MS) {
|
||||
manager.getWorkInfoByIdFlow(request.id).first { info ->
|
||||
info != null && info.state.isFinished
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
"conversion did not succeed: " +
|
||||
terminal?.outputData?.getString(ConversionWorker.KEY_ERROR),
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
terminal?.state,
|
||||
)
|
||||
val path = requireNotNull(terminal?.outputData?.getString(ConversionWorker.KEY_OUTPUT_PATH))
|
||||
val output = File(path).also { staged += it }
|
||||
assertTrue("output is empty", output.length() > 0)
|
||||
Result(
|
||||
output = output,
|
||||
engineUsed = terminal?.outputData
|
||||
?.getString(ConversionWorker.KEY_ENGINE_USED).orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun trackMimesOf(file: File): List<String> {
|
||||
val extractor = MediaExtractor()
|
||||
return try {
|
||||
extractor.setDataSource(file.absolutePath)
|
||||
(0 until extractor.trackCount)
|
||||
.map { extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME).orEmpty() }
|
||||
} finally {
|
||||
extractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun videoMimeOf(file: File): String? =
|
||||
trackMimesOf(file).firstOrNull { it.startsWith("video/") }
|
||||
|
||||
private fun durationMsOf(file: File): Long {
|
||||
val extractor = MediaExtractor()
|
||||
return try {
|
||||
extractor.setDataSource(file.absolutePath)
|
||||
(0 until extractor.trackCount)
|
||||
.map { extractor.getTrackFormat(it) }
|
||||
.filter { it.containsKey(MediaFormat.KEY_DURATION) }
|
||||
.maxOfOrNull { it.getLong(MediaFormat.KEY_DURATION) / 1000 } ?: 0L
|
||||
} finally {
|
||||
extractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_MS = 180_000L
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ object FakeFailures {
|
||||
override suspend fun transcode(
|
||||
input: Uri,
|
||||
output: File,
|
||||
format: OutputFormat,
|
||||
request: ConversionRequest,
|
||||
onProgress: (Int) -> Unit,
|
||||
) {
|
||||
called = true
|
||||
|
||||
@@ -66,7 +66,7 @@ class ForcedFailureTest {
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = SAMPLE,
|
||||
sizeBytes = input.length(),
|
||||
format = format,
|
||||
spec = format.spec,
|
||||
quality = QualityTier.FAST,
|
||||
)
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ class HardwareFallbackTest {
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = SAMPLE,
|
||||
sizeBytes = input.length(),
|
||||
format = OutputFormat.MP4_H265,
|
||||
spec = OutputFormat.MP4_H265.spec,
|
||||
// Fast deliberately: this is the tier the router sends to Media3, so it is
|
||||
// the tier where the fallback has to rescue the conversion.
|
||||
quality = QualityTier.FAST,
|
||||
|
||||
@@ -53,7 +53,7 @@ class FFmpegEngineTest {
|
||||
val out = outputFor("out_${format.name.lowercase()}.${format.extension}")
|
||||
runBlocking {
|
||||
engine.run(
|
||||
request = ConversionRequest(format = format, quality = quality),
|
||||
request = ConversionRequest(spec = format.spec, quality = quality),
|
||||
inputPath = input.absolutePath,
|
||||
output = out,
|
||||
durationMs = 3_000,
|
||||
@@ -155,7 +155,7 @@ class FFmpegEngineTest {
|
||||
val failure = runCatching {
|
||||
runBlocking {
|
||||
engine.run(
|
||||
request = ConversionRequest(format = OutputFormat.MP4_H264),
|
||||
request = ConversionRequest(spec = OutputFormat.MP4_H264.spec),
|
||||
inputPath = "/does/not/exist.mp4",
|
||||
output = out,
|
||||
durationMs = 1_000,
|
||||
|
||||
@@ -10,9 +10,12 @@ import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import org.libremediaconverter.codec.AndroidDeviceCodecs
|
||||
import org.libremediaconverter.model.Engine
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
@@ -78,7 +81,7 @@ class ConversionWorkerTest {
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = "worker_sample.mp4",
|
||||
sizeBytes = input.length(),
|
||||
format = OutputFormat.MP4_H264,
|
||||
spec = OutputFormat.MP4_H264.spec,
|
||||
)
|
||||
workManager.enqueue(request).result.get()
|
||||
|
||||
@@ -114,7 +117,7 @@ class ConversionWorkerTest {
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = "worker_sample.mp4",
|
||||
sizeBytes = input.length(),
|
||||
format = OutputFormat.MP3,
|
||||
spec = OutputFormat.MP3.spec,
|
||||
)
|
||||
workManager.enqueue(request).result.get()
|
||||
|
||||
@@ -152,7 +155,7 @@ class ConversionWorkerTest {
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = "worker_sample.mp4",
|
||||
sizeBytes = input.length(),
|
||||
format = OutputFormat.MP4_H265,
|
||||
spec = OutputFormat.MP4_H265.spec,
|
||||
quality = QualityTier.FAST,
|
||||
)
|
||||
workManager.enqueue(request).result.get()
|
||||
@@ -181,7 +184,7 @@ class ConversionWorkerTest {
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = "worker_sample.mp4",
|
||||
sizeBytes = input.length(),
|
||||
format = OutputFormat.MP4_H264,
|
||||
spec = OutputFormat.MP4_H264.spec,
|
||||
quality = QualityTier.BEST,
|
||||
)
|
||||
workManager.enqueue(request).result.get()
|
||||
@@ -199,9 +202,50 @@ class ConversionWorkerTest {
|
||||
|
||||
@Test
|
||||
fun outputNameTakesTheExtensionOfTheChosenFormat() {
|
||||
assertEquals("clip_converted.mp3", ConversionWorker.outputNameFor("clip.mp4", OutputFormat.MP3))
|
||||
assertEquals("clip_converted.gif", ConversionWorker.outputNameFor("clip.mov", OutputFormat.GIF))
|
||||
assertEquals("clip_converted.mkv", ConversionWorker.outputNameFor("clip", OutputFormat.MKV_H264))
|
||||
assertEquals(
|
||||
"clip_converted.mp3",
|
||||
ConversionWorker.outputNameFor("clip.mp4", OutputFormat.MP3.spec),
|
||||
)
|
||||
assertEquals(
|
||||
"clip_converted.gif",
|
||||
ConversionWorker.outputNameFor("clip.mov", OutputFormat.GIF.spec),
|
||||
)
|
||||
assertEquals(
|
||||
"clip_converted.mkv",
|
||||
ConversionWorker.outputNameFor("clip", OutputFormat.MKV_H264.spec),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The extension follows the container and whether a video track survives.
|
||||
*
|
||||
* It used to be a literal on each preset, so the only names reachable were the ones somebody
|
||||
* had enumerated. Matroska without video is `.mka` and MP4 without video is `.m4a` — neither
|
||||
* had a preset before, and both are now one tap away in the Advanced picker.
|
||||
*/
|
||||
@Test
|
||||
fun outputNameDistinguishesAudioOnlyVariantsOfAContainer() {
|
||||
assertEquals(
|
||||
"clip_converted.mka",
|
||||
ConversionWorker.outputNameFor(
|
||||
"clip.mkv",
|
||||
OutputSpec(Container.MKV, VideoCodec.NONE, AudioCodec.FLAC),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"clip_converted.m4a",
|
||||
ConversionWorker.outputNameFor(
|
||||
"clip.mp4",
|
||||
OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.AAC),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"clip_converted.mkv",
|
||||
ConversionWorker.outputNameFor(
|
||||
"clip.mp4",
|
||||
OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
Reference in New Issue
Block a user