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>
101 lines
3.8 KiB
Kotlin
101 lines
3.8 KiB
Kotlin
package org.libremediaconverter.fallback
|
|
|
|
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 org.libremediaconverter.model.OutputFormat
|
|
import org.libremediaconverter.model.QualityTier
|
|
import org.libremediaconverter.work.ConversionWorker
|
|
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.assertTrue
|
|
import org.junit.Before
|
|
import org.junit.Test
|
|
import org.junit.runner.RunWith
|
|
import java.io.File
|
|
|
|
/**
|
|
* The runtime fallback, driven by a file Media3 genuinely cannot decode.
|
|
*
|
|
* The fixture is H.264 **High 4:4:4 Predictive**. Hardware AVC decoders implement High
|
|
* 4:2:0, and Android's software `c2.google.avc.decoder` does not cover 4:4:4 either, so
|
|
* Media3 fails partway through the export on every device.
|
|
*
|
|
* The static routing rules cannot predict that: the container is MP4, the codec reports
|
|
* as "h264", and the device advertises AVC decode and encode. Everything looks viable
|
|
* until the codec is configured. This is the one case that proves the runtime fallback
|
|
* works, so it deliberately uses a **committed fixture** rather than media staged by
|
|
* hand — a regression test that silently skips is worse than no test, because the count
|
|
* still reads as coverage.
|
|
*
|
|
* The fixture was produced with x264, which the host toolchain cannot do (Fedora's
|
|
* ffmpeg ships openh264, which is Constrained Baseline only):
|
|
*
|
|
* ffmpeg -f lavfi -i testsrc=duration=3:size=320x240:rate=15 \
|
|
* -f lavfi -i sine=frequency=440:duration=3 \
|
|
* -c:v libx264 -profile:v high444 -pix_fmt yuv444p -preset ultrafast \
|
|
* -c:a aac -shortest sample_h264_444.mp4
|
|
*/
|
|
@UnstableApi
|
|
@RunWith(AndroidJUnit4::class)
|
|
class HardwareFallbackTest {
|
|
|
|
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
|
private val workManager = WorkManager.getInstance(context)
|
|
private lateinit var input: File
|
|
|
|
@Before
|
|
fun setUp() {
|
|
input = File(context.cacheDir, SAMPLE)
|
|
InstrumentationRegistry.getInstrumentation().context.assets
|
|
.open(SAMPLE)
|
|
.use { asset -> input.outputStream().use { asset.copyTo(it) } }
|
|
}
|
|
|
|
@After
|
|
fun tearDown() {
|
|
input.delete()
|
|
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
|
|
}
|
|
|
|
@Test
|
|
fun aFileMedia3CannotDecodeStillConvertsViaFfmpeg(): Unit = runBlocking {
|
|
val request = ConversionWorker.request(
|
|
inputUri = Uri.fromFile(input),
|
|
displayName = SAMPLE,
|
|
sizeBytes = input.length(),
|
|
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,
|
|
)
|
|
workManager.enqueue(request).result.get()
|
|
|
|
val terminal = withTimeout(TIMEOUT_MS) {
|
|
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
|
|
}
|
|
|
|
val error = terminal?.outputData?.getString(ConversionWorker.KEY_ERROR)
|
|
assertEquals(
|
|
"a file Media3 cannot decode must still convert, but failed with: $error",
|
|
WorkInfo.State.SUCCEEDED,
|
|
terminal?.state,
|
|
)
|
|
|
|
val out = File(terminal!!.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)!!)
|
|
assertTrue("no output produced", out.exists() && out.length() > 0)
|
|
out.delete()
|
|
}
|
|
|
|
private companion object {
|
|
const val SAMPLE = "sample_h264_444.mp4"
|
|
const val TIMEOUT_MS = 600_000L
|
|
}
|
|
}
|