Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f65578b1f7 | ||
|
|
b38ad6a683 | ||
|
|
9a0f494e26 | ||
|
|
f3478706b3 | ||
|
|
61c400d2c6 | ||
|
|
d83775d5c6 | ||
|
|
e90f5a801c | ||
|
|
68015b3374 | ||
|
|
7e09f010c7 | ||
|
|
49249be280 | ||
|
|
2125763ebf | ||
|
|
6d700f0014 |
@@ -21,6 +21,11 @@ import org.libremediaconverter.model.VideoCodec
|
||||
* words, "cannot be tested for correctness". It is a hint, not a guarantee, which is
|
||||
* why the router treats a failed hardware export as a signal to fall back rather
|
||||
* than trusting this up front.
|
||||
* - **An enumeration that fails answers no to everything**, which sends every job to
|
||||
* FFmpeg. Empty sets are not a permissive default: `canEncode` looks a MIME type up in
|
||||
* [hardwareEncodeMimes] and finds nothing there. That is the intended answer — FFmpeg
|
||||
* can do whatever Media3 can, only slower — but it is the opposite of what this class
|
||||
* said until #194, so it is written down rather than left to be re-derived.
|
||||
*/
|
||||
class AndroidDeviceCodecs private constructor(
|
||||
private val hardwareEncodeMimes: Set<String>,
|
||||
@@ -46,22 +51,62 @@ class AndroidDeviceCodecs private constructor(
|
||||
|
||||
fun get(): AndroidDeviceCodecs = cached ?: synchronized(this) { cached ?: probe().also { cached = it } }
|
||||
|
||||
private fun probe(): AndroidDeviceCodecs {
|
||||
/**
|
||||
* One entry of the platform's codec list, reduced to what the rules below read.
|
||||
*
|
||||
* The five booleans and the type list are the whole of what [capabilitiesFrom] needs, and
|
||||
* none of them can be set on a `MediaCodecInfo` from a test: Robolectric ships
|
||||
* `MediaCodecInfoBuilder`, but it has no `setIsAlias` and no `setCanonicalName`, which is
|
||||
* exactly the objection #133 raised against reaching this code through
|
||||
* `ShadowMediaCodecList`. That objection is about the shadow. It does not apply to a
|
||||
* function that takes its own entry type, which is why this exists.
|
||||
*/
|
||||
internal data class CodecEntry(
|
||||
val canonicalName: String,
|
||||
val isAlias: Boolean,
|
||||
val isEncoder: Boolean,
|
||||
val isHardwareAccelerated: Boolean,
|
||||
val isSoftwareOnly: Boolean,
|
||||
val supportedTypes: List<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* The enumeration rules, over entries a caller chooses.
|
||||
*
|
||||
* [probe] is the only production caller and supplies the real codec list; a test supplies
|
||||
* its own, which is the point — the two rules this class's KDoc calls out as easy to get
|
||||
* wrong, the alias skip and the canonical-name dedup, are unreachable any other way.
|
||||
*
|
||||
* **`enumerate` returns a `Sequence`, deliberately.** The `runCatching` has to wrap the
|
||||
* *iteration* rather than a list built before it, because a `MediaCodecInfo` whose
|
||||
* properties throw does so partway through — and when that happens the codecs already read
|
||||
* are kept. Taking a `List` here would move that throw outside the loop and silently turn a
|
||||
* partial answer into an empty one. That behaviour predates this seam; a `List` parameter
|
||||
* would have changed it as a side effect of a refactor.
|
||||
*
|
||||
* **An enumeration that fails answers restrictively, and that is deliberate.** The sets
|
||||
* come back empty, and `"video/avc" in emptySet()` is `false`, so [canEncode] and
|
||||
* [canDecode] both answer no and every job routes to FFmpeg. FFmpeg can do everything
|
||||
* Media3 can, only slower, so refusing the hardware path is the safe reading of "we could
|
||||
* not find out what this device supports". This used to log "assuming permissive", which
|
||||
* described the opposite of what the code does.
|
||||
*/
|
||||
internal fun capabilitiesFrom(enumerate: () -> Sequence<CodecEntry>): AndroidDeviceCodecs {
|
||||
val encoders = mutableSetOf<String>()
|
||||
val decoders = mutableSetOf<String>()
|
||||
val seen = mutableSetOf<String>()
|
||||
|
||||
runCatching {
|
||||
MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.forEach { info ->
|
||||
enumerate().forEach { entry ->
|
||||
// Aliases point at the same underlying codec; counting both would
|
||||
// double-count capabilities.
|
||||
if (info.isAlias) return@forEach
|
||||
if (!seen.add(info.canonicalName)) return@forEach
|
||||
if (entry.isAlias) return@forEach
|
||||
if (!seen.add(entry.canonicalName)) return@forEach
|
||||
|
||||
info.supportedTypes.forEach { mime ->
|
||||
entry.supportedTypes.forEach { mime ->
|
||||
if (!mime.startsWith("video/")) return@forEach
|
||||
if (info.isEncoder) {
|
||||
if (info.isHardwareAccelerated && !info.isSoftwareOnly) {
|
||||
if (entry.isEncoder) {
|
||||
if (entry.isHardwareAccelerated && !entry.isSoftwareOnly) {
|
||||
encoders += mime
|
||||
}
|
||||
} else {
|
||||
@@ -69,12 +114,32 @@ class AndroidDeviceCodecs private constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Codec enumeration failed; assuming permissive.", it) }
|
||||
}.onFailure { Log.w(TAG, "Codec enumeration failed; routing everything to FFmpeg.", it) }
|
||||
|
||||
Log.i(TAG, "Hardware video encoders: $encoders")
|
||||
return AndroidDeviceCodecs(encoders, decoders)
|
||||
}
|
||||
|
||||
/**
|
||||
* The thin edge: the real codec list, mapped onto [CodecEntry] one at a time.
|
||||
*
|
||||
* Lazily, so a property that throws does it inside [capabilitiesFrom]'s `runCatching` and
|
||||
* on the entry that caused it — see that function's note on why the parameter is a
|
||||
* `Sequence`.
|
||||
*/
|
||||
private fun probe(): AndroidDeviceCodecs = capabilitiesFrom {
|
||||
MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.asSequence().map { info ->
|
||||
CodecEntry(
|
||||
canonicalName = info.canonicalName,
|
||||
isAlias = info.isAlias,
|
||||
isEncoder = info.isEncoder,
|
||||
isHardwareAccelerated = info.isHardwareAccelerated,
|
||||
isSoftwareOnly = info.isSoftwareOnly,
|
||||
supportedTypes = info.supportedTypes.toList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `internal` rather than `private` so the cross-check test can ask what a [VideoCodec]
|
||||
* means here and compare it with what [NAME_TO_MIME] says the same codec's names mean.
|
||||
|
||||
@@ -221,12 +221,39 @@ object MediaProbe {
|
||||
null
|
||||
}
|
||||
|
||||
private fun readMediaInformation(path: String): FFprobeInfo? {
|
||||
// ffmpeg-kit-next is compiled from Kotlin with private backing fields, so these have to go
|
||||
// through the Java getters rather than property syntax.
|
||||
val info: MediaInformation = FFprobeKit.getMediaInformation(path).getMediaInformation()
|
||||
?: return null
|
||||
/**
|
||||
* The thin edge: spawn FFprobe, hand what it said to [ffprobeInfoFrom].
|
||||
*
|
||||
* Everything device-bound is on this line and the null check under it. What FFprobe *said* is a
|
||||
* `MediaInformation`, which is an ordinary object over a `JSONObject` — so the reading of it is
|
||||
* a decision a test can choose the inputs for, and it lives below rather than here.
|
||||
*/
|
||||
private fun readMediaInformation(path: String): FFprobeInfo? =
|
||||
FFprobeKit.getMediaInformation(path).getMediaInformation()?.let(::ffprobeInfoFrom)
|
||||
|
||||
/**
|
||||
* What FFprobe's answer means, as a function of the answer alone.
|
||||
*
|
||||
* `internal` for the same reason [Extracted] and [FFprobeInfo] are: a test cannot name it
|
||||
* otherwise, and the JVM test source set is a friend of `main`.
|
||||
*
|
||||
* **JVM-safe, verified rather than assumed.** `javap` over the committed AAR's runtime jar:
|
||||
* `MediaInformation(JSONObject, List<StreamInformation>, List<Chapter>)` and
|
||||
* `StreamInformation(JSONObject)` are plain public constructors, and neither class's `<clinit>`
|
||||
* touches the native library — so a test builds its own without `libffmpegkit` being present.
|
||||
* That is the whole reason this split is worth making: `readMediaInformation` was 114 missed
|
||||
* instructions and 24 missed branches, of which exactly one line needed a device.
|
||||
*
|
||||
* The subtle part is the **second argument to [containerFrom]**. `matroska,webm` is reported
|
||||
* for both MKV and WebM — they share a demuxer — so the video codec is the only thing that
|
||||
* separates them, and dropping it silently turns every VP9 WebM into an MKV. `containerFrom`
|
||||
* has thirty-three covered branches of its own and none of them can notice that, because the
|
||||
* mistake is at the call rather than in the callee.
|
||||
*
|
||||
* ffmpeg-kit-next is compiled from Kotlin with private backing fields, so these go through the
|
||||
* Java getters rather than property syntax.
|
||||
*/
|
||||
internal fun ffprobeInfoFrom(info: MediaInformation): FFprobeInfo {
|
||||
val streams = info.getStreams().orEmpty()
|
||||
val video = streams.firstOrNull { it.getType() == "video" }
|
||||
val audio = streams.firstOrNull { it.getType() == "audio" }
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package org.libremediaconverter.codec
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* The rules `AndroidDeviceCodecs.probe()` applies to the platform's codec list.
|
||||
*
|
||||
* ## Why this is not a third run of the #86/#133 spike
|
||||
*
|
||||
* #86 closed `probe()` as device-bound. #133 re-opened the question with
|
||||
* `ShadowMediaCodecList` in hand and closed it again, for a reason that was right about what it
|
||||
* was answering: `MediaCodecInfoBuilder` "has no `setIsAlias` and no `setCanonicalName`, so the
|
||||
* alias skip and the canonical-name dedup — the two things the class's KDoc calls out as easy to
|
||||
* get wrong — are not reachable through it."
|
||||
*
|
||||
* **That objection is about the shadow.** It does not apply to a function that takes its own entry
|
||||
* type, which is what `capabilitiesFrom` now does. The half #133 named as unreachable is the half
|
||||
* this file spends most of its cases on.
|
||||
*
|
||||
* ## What made the seam worth cutting, which is not coverage
|
||||
*
|
||||
* The `runCatching` fallback logged *"assuming permissive"* and returned empty sets — and empty
|
||||
* sets are **restrictive**: `"video/avc" in emptySet()` is `false`, so `canEncode` and `canDecode`
|
||||
* both answer no and every job routes to FFmpeg. The code was right and the message described the
|
||||
* opposite of it. That is pinned below, so whichever reading a future change takes, it has to say
|
||||
* so out loud.
|
||||
*
|
||||
* Robolectric only because `capabilitiesFrom` logs what it found; the rules themselves are pure.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class CodecEnumerationTest {
|
||||
|
||||
/**
|
||||
* The alias skip, in the one arrangement where it is observable — and finding that arrangement
|
||||
* is the whole of this test.
|
||||
*
|
||||
* A first attempt listed the alias *after* the codec it aliases and passed with the skip
|
||||
* deleted, because `canonicalName` is shared and the dedup below catches the second entry
|
||||
* either way. The two rules overlap, so a fixture that does not separate them tests neither.
|
||||
*
|
||||
* What separates them is **order**. `MediaCodecInfo.getCanonicalName()` on an alias returns the
|
||||
* underlying codec's name, so an alias arriving first claims that name in `seen` and has its
|
||||
* own `supportedTypes` credited — and then the real codec is dropped by the dedup. Without the
|
||||
* alias skip the device is described by whichever entry the platform happened to list first.
|
||||
*
|
||||
* That also says what the rule is worth. With a `Set` accumulator, an alias declaring the same
|
||||
* types as its codec changes nothing whichever order they arrive in; the skip earns its place
|
||||
* only when the two disagree, which is exactly when believing the wrong one matters.
|
||||
*/
|
||||
@Test
|
||||
fun `an alias listed before the codec it aliases does not describe the device`() {
|
||||
val codecs = capabilities(
|
||||
entry("c2.qti.avc.encoder", encoder = true, types = listOf(HEVC), alias = true),
|
||||
entry("c2.qti.avc.encoder", encoder = true, types = listOf(AVC)),
|
||||
)
|
||||
|
||||
assertTrue("the real codec's types are the device's", codecs.canEncode(VideoCodec.H264))
|
||||
assertFalse(
|
||||
"an alias must not be credited with types the codec it aliases never claimed",
|
||||
codecs.canEncode(VideoCodec.H265),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two entries sharing a canonical name are read once`() {
|
||||
val codecs = capabilities(
|
||||
entry("c2.qti.avc.encoder", encoder = true, types = listOf(AVC)),
|
||||
entry("c2.qti.avc.encoder", encoder = true, types = listOf(HEVC)),
|
||||
)
|
||||
|
||||
assertEquals(setOf(AVC), codecs.hardwareEncoders())
|
||||
}
|
||||
|
||||
/**
|
||||
* Both halves of the hardware predicate, one arm at a time.
|
||||
*
|
||||
* A vendor may declare a codec hardware-accelerated *and* software-only; the class KDoc is
|
||||
* explicit that the first flag "cannot be tested for correctness", so the second is what stops
|
||||
* a mislabelled software encoder being treated as the fast path.
|
||||
*/
|
||||
@Test
|
||||
fun `an encoder counts as hardware only when it is accelerated and not software-only`() {
|
||||
assertEquals(
|
||||
setOf(AVC),
|
||||
capabilities(entry("hw", encoder = true, accelerated = true, types = listOf(AVC))).hardwareEncoders(),
|
||||
)
|
||||
assertEquals(
|
||||
emptySet<String>(),
|
||||
capabilities(entry("sw", encoder = true, accelerated = false, types = listOf(AVC))).hardwareEncoders(),
|
||||
)
|
||||
assertEquals(
|
||||
"a codec claiming both must not be trusted as hardware",
|
||||
emptySet<String>(),
|
||||
capabilities(
|
||||
entry("both", encoder = true, accelerated = true, softwareOnly = true, types = listOf(AVC)),
|
||||
).hardwareEncoders(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoders are collected regardless of the hardware flags, and that asymmetry is the design.
|
||||
*
|
||||
* `canDecode` asks whether the platform can read the input at all — a software decoder answers
|
||||
* that as well as a hardware one. `canEncode` asks whether the *fast path* exists, which is a
|
||||
* different question and why only encoders are filtered.
|
||||
*/
|
||||
@Test
|
||||
fun `a software decoder still counts as something the platform can read`() {
|
||||
val codecs = capabilities(
|
||||
entry(
|
||||
"c2.android.avc.decoder",
|
||||
encoder = false,
|
||||
accelerated = false,
|
||||
softwareOnly = true,
|
||||
types = listOf(AVC),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(codecs.canDecode("h264"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio types are ignored on both sides`() {
|
||||
val codecs = capabilities(
|
||||
entry("aac.encoder", encoder = true, accelerated = true, types = listOf("audio/mp4a-latm")),
|
||||
entry("aac.decoder", encoder = false, types = listOf("audio/mp4a-latm")),
|
||||
)
|
||||
|
||||
assertEquals(emptySet<String>(), codecs.hardwareEncoders())
|
||||
// Not "the platform cannot decode AAC" -- `canDecode` is asked about *video* codec names,
|
||||
// and an unknown name is answered permissively. The point is that nothing audio reached
|
||||
// either set.
|
||||
assertTrue("an unknown name stays permissive", codecs.canDecode("something-nobody-named"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure fallback, pinned as the restrictive answer it actually is.
|
||||
*
|
||||
* #194 decided this rather than assuming it: the code stays, the message changes. If a later
|
||||
* change wants the permissive reading its old log line described, this test is what makes that
|
||||
* a decision instead of a drift.
|
||||
*/
|
||||
@Test
|
||||
fun `an enumeration that fails sends every job to FFmpeg`() {
|
||||
val codecs = AndroidDeviceCodecs.capabilitiesFrom { error("MediaCodecList exploded") }
|
||||
|
||||
assertFalse("a failed enumeration must not claim a hardware encoder", codecs.canEncode(VideoCodec.H264))
|
||||
assertFalse(codecs.canDecode("h264"))
|
||||
assertEquals(emptySet<String>(), codecs.hardwareEncoders())
|
||||
}
|
||||
|
||||
/**
|
||||
* A list that throws partway keeps what it already read.
|
||||
*
|
||||
* This predates the seam — `runCatching` has always wrapped the iteration rather than a list
|
||||
* built before it — and it is asserted here because the seam is where it could quietly have
|
||||
* been lost. Taking a `List` instead of a `Sequence` would move the throw outside the loop and
|
||||
* turn this partial answer into an empty one, with no test to notice.
|
||||
*/
|
||||
@Test
|
||||
fun `codecs read before a failing entry are kept`() {
|
||||
val codecs = AndroidDeviceCodecs.capabilitiesFrom {
|
||||
sequence {
|
||||
yield(entry("good", encoder = true, accelerated = true, types = listOf(AVC)))
|
||||
error("the sixth codec's properties threw")
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(setOf(AVC), codecs.hardwareEncoders())
|
||||
}
|
||||
|
||||
private fun capabilities(vararg entries: AndroidDeviceCodecs.Companion.CodecEntry) =
|
||||
AndroidDeviceCodecs.capabilitiesFrom { entries.asSequence() }
|
||||
|
||||
private fun entry(
|
||||
canonicalName: String,
|
||||
encoder: Boolean,
|
||||
accelerated: Boolean = true,
|
||||
softwareOnly: Boolean = false,
|
||||
alias: Boolean = false,
|
||||
types: List<String>,
|
||||
) = AndroidDeviceCodecs.Companion.CodecEntry(
|
||||
canonicalName = canonicalName,
|
||||
isAlias = alias,
|
||||
isEncoder = encoder,
|
||||
isHardwareAccelerated = accelerated,
|
||||
isSoftwareOnly = softwareOnly,
|
||||
supportedTypes = types,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val AVC = "video/avc"
|
||||
const val HEVC = "video/hevc"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import com.arthenica.ffmpegkit.MediaInformation
|
||||
import com.arthenica.ffmpegkit.StreamInformation
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* What FFprobe's answer means, read as a function of the answer alone.
|
||||
*
|
||||
* `readMediaInformation` was 114 missed instructions and 24 missed branches — the second-biggest
|
||||
* block on the wave-4 report — of which **exactly one line needed a device**:
|
||||
*
|
||||
* ```kotlin
|
||||
* FFprobeKit.getMediaInformation(path).getMediaInformation()
|
||||
* ```
|
||||
*
|
||||
* Everything after it reads an ordinary object. `javap` over the committed AAR's runtime jar:
|
||||
* `MediaInformation(JSONObject, List<StreamInformation>, List<Chapter>)` and
|
||||
* `StreamInformation(JSONObject)` are plain public constructors, and neither class's `<clinit>`
|
||||
* loads the native library — so the fixtures below are built without `libffmpegkit` present.
|
||||
*
|
||||
* ## The one that matters
|
||||
*
|
||||
* `containerFrom(formatName, video?.getCodec())`. FFprobe reports `matroska,webm` for **both** MKV
|
||||
* and WebM, because they share a demuxer, so the video codec is the only thing separating them.
|
||||
* `containerFrom` has thirty-three covered branches of its own and not one of them can notice the
|
||||
* argument being dropped — the mistake would be at the call, not in the callee, and every existing
|
||||
* `containerFrom` test would stay green while every VP9 WebM quietly became an MKV.
|
||||
*
|
||||
* Robolectric only for `org.json`, which is a stub in a plain JVM test.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FFprobeMappingTest {
|
||||
|
||||
@Test
|
||||
fun `the video codec decides between matroska and webm`() {
|
||||
assertEquals(
|
||||
Container.WEBM,
|
||||
MediaProbe.ffprobeInfoFrom(info("matroska,webm", stream("video", "vp9"))).container,
|
||||
)
|
||||
assertEquals(
|
||||
Container.MKV,
|
||||
MediaProbe.ffprobeInfoFrom(info("matroska,webm", stream("video", "h264"))).container,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same format name with no video stream at all, which is what makes the case above about
|
||||
* the *argument* rather than about the format string.
|
||||
*/
|
||||
@Test
|
||||
fun `a matroska container with no video track cannot be told from webm and is not guessed`() {
|
||||
val read = MediaProbe.ffprobeInfoFrom(info("matroska,webm", stream("audio", "opus")))
|
||||
|
||||
assertEquals(Container.MKV, read.container)
|
||||
assertNull(read.videoCodec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first stream of each type wins`() {
|
||||
val read = MediaProbe.ffprobeInfoFrom(
|
||||
info(
|
||||
"mov,mp4,m4a,3gp,3g2,mj2",
|
||||
stream("video", "h264", width = 1920, height = 1080),
|
||||
stream("video", "hevc", width = 640, height = 480),
|
||||
stream("audio", "aac"),
|
||||
stream("audio", "mp3"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("h264", read.videoCodec)
|
||||
assertEquals("aac", read.audioCodec)
|
||||
assertEquals(1920, read.width)
|
||||
assertEquals(1080, read.height)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dimensions come from the stream the codec came from, not from whichever stream has some.
|
||||
*
|
||||
* The fixture is deliberately awkward: the chosen video stream carries **no** dimensions and a
|
||||
* later one does. That is a real shape — FFprobe omits `width`/`height` for a stream it could
|
||||
* not measure — and it is the only arrangement that separates the two readings.
|
||||
*
|
||||
* A first version of this file asserted the dimensions inside the case above, where the chosen
|
||||
* stream was also the first one carrying any. Replacing `video?.getWidth()` with
|
||||
* `streams.firstNotNullOfOrNull { it.getWidth() }` gave the same answer there and **the
|
||||
* mutation survived**. It reddens here.
|
||||
*/
|
||||
@Test
|
||||
fun `a video stream with no dimensions reports none rather than borrowing another stream's`() {
|
||||
val read = MediaProbe.ffprobeInfoFrom(
|
||||
info(
|
||||
"mov,mp4,m4a,3gp,3g2,mj2",
|
||||
stream("video", "h264"),
|
||||
stream("video", "hevc", width = 640, height = 480),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("h264", read.videoCodec)
|
||||
assertEquals(0, read.width)
|
||||
assertEquals(0, read.height)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream order is the file's, not a promise. An audio-first container must read the same as a
|
||||
* video-first one.
|
||||
*/
|
||||
@Test
|
||||
fun `an audio track listed first does not become the video track`() {
|
||||
val read = MediaProbe.ffprobeInfoFrom(
|
||||
info("mov,mp4,m4a,3gp,3g2,mj2", stream("audio", "aac"), stream("video", "h264")),
|
||||
)
|
||||
|
||||
assertEquals("h264", read.videoCodec)
|
||||
assertEquals("aac", read.audioCodec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a duration in seconds becomes milliseconds`() {
|
||||
assertEquals(12_345L, MediaProbe.ffprobeInfoFrom(info("mp4", duration = "12.345")).durationMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Both ways a duration can be absent, and neither may throw.
|
||||
*
|
||||
* FFprobe reports `"N/A"` for a stream it could not measure, and omits the key entirely for
|
||||
* some containers. `toDoubleOrNull` is what keeps the second from being an exception on the
|
||||
* file-pick path, where there is no user-visible failure to report it as.
|
||||
*/
|
||||
@Test
|
||||
fun `a duration that is not a number is no duration rather than a crash`() {
|
||||
assertEquals(0L, MediaProbe.ffprobeInfoFrom(info("mp4", duration = "N/A")).durationMs)
|
||||
assertEquals(0L, MediaProbe.ffprobeInfoFrom(info("mp4", duration = null)).durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file with no streams reports nothing rather than defaults that look measured`() {
|
||||
val read = MediaProbe.ffprobeInfoFrom(info("mp4"))
|
||||
|
||||
assertNull(read.videoCodec)
|
||||
assertNull(read.audioCodec)
|
||||
assertEquals(0, read.width)
|
||||
assertEquals(0, read.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an image format is reported as one`() {
|
||||
assertTrue(MediaProbe.ffprobeInfoFrom(info("png_pipe", stream("video", "png"))).isImage)
|
||||
assertFalse(MediaProbe.ffprobeInfoFrom(info("mp4", stream("video", "h264"))).isImage)
|
||||
}
|
||||
|
||||
private fun stream(type: String, codec: String, width: Int? = null, height: Int? = null) = StreamInformation(
|
||||
JSONObject().apply {
|
||||
put(StreamInformation.KEY_TYPE, type)
|
||||
put(StreamInformation.KEY_CODEC, codec)
|
||||
width?.let { put(StreamInformation.KEY_WIDTH, it) }
|
||||
height?.let { put(StreamInformation.KEY_HEIGHT, it) }
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* The format properties are **nested** under `"format"`, which is how FFprobe reports them and
|
||||
* what `MediaInformation` reads: `getFormat()` resolves through `getStringFormatProperty`, not
|
||||
* off the top-level object. A first version of this helper put the keys at the top level and
|
||||
* every format-dependent case failed with a null container, which is worth recording here so
|
||||
* the next fixture does not have to rediscover it.
|
||||
*
|
||||
* Streams are the other half and are *not* nested — they come from the constructor argument.
|
||||
*/
|
||||
private fun info(formatName: String, vararg streams: StreamInformation, duration: String? = "1.0") =
|
||||
MediaInformation(
|
||||
JSONObject().apply {
|
||||
put(
|
||||
MediaInformation.KEY_FORMAT_PROPERTIES,
|
||||
JSONObject().apply {
|
||||
put(MediaInformation.KEY_FORMAT, formatName)
|
||||
duration?.let { put(MediaInformation.KEY_DURATION, it) }
|
||||
},
|
||||
)
|
||||
},
|
||||
streams.toList(),
|
||||
emptyList(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package org.libremediaconverter.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* The theme called the way the app calls it: with no arguments at all.
|
||||
*
|
||||
* [ThemeColorSchemeTest] resolves every branch of the `when` and always passes `darkTheme`
|
||||
* explicitly, so the `$default` bridge is never entered and **`isSystemInDarkTheme()` is never
|
||||
* called**. `MainActivity.kt:79` is its only default-argument caller and does not execute on the
|
||||
* JVM, which left the app's actual call shape the one nothing exercised —
|
||||
* `LibreMediaConverterTheme` reported `mi=21, mb=6, cb=12` at method level.
|
||||
*
|
||||
* ## Not #68
|
||||
*
|
||||
* #68 is about the two **unreachable** arms, `DarkColorScheme` and `LightColorScheme`, which cannot
|
||||
* run because `dynamicColor` is always `true` and nothing can flip it. That is an open product
|
||||
* decision. This is the reachable half — whether the default follows the system — and closing it
|
||||
* does not close that.
|
||||
*
|
||||
* ## Why the assertion compares schemes rather than reading a number
|
||||
*
|
||||
* A luminance threshold would be a guess about the device palette. What is asserted instead is that
|
||||
* the no-argument call resolves to **the same scheme** an explicit `darkTheme` of the matching
|
||||
* value does, and a different one from its opposite. That holds whatever palette the platform
|
||||
* hands back, and it is exactly the claim: the default reads the system rather than picking a side.
|
||||
*
|
||||
* Both schemes are resolved in one composition because `setContent` may be called once per test.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ThemeFollowsSystemTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "+night")
|
||||
fun `with no arguments the theme follows a system in dark mode`() {
|
||||
val resolved = resolve()
|
||||
|
||||
assertEquals("the default must resolve what darkTheme = true does", resolved.dark, resolved.bare)
|
||||
assertNotEquals(resolved.light, resolved.bare)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "+notnight")
|
||||
fun `with no arguments the theme follows a system in light mode`() {
|
||||
val resolved = resolve()
|
||||
|
||||
assertEquals("the default must resolve what darkTheme = false does", resolved.light, resolved.bare)
|
||||
assertNotEquals(resolved.dark, resolved.bare)
|
||||
}
|
||||
|
||||
/**
|
||||
* The three colours are read together as one value, because any single one could coincide
|
||||
* between the two schemes on some palette while the schemes themselves differ. Background is
|
||||
* what dark mode is chiefly about; primary and surface are along to make a coincidence
|
||||
* implausible rather than merely unlikely.
|
||||
*/
|
||||
private data class Fingerprint(val background: Long, val primary: Long, val surface: Long)
|
||||
|
||||
private fun ColorScheme.fingerprint() =
|
||||
Fingerprint(background.value.toLong(), primary.value.toLong(), surface.value.toLong())
|
||||
|
||||
private class Resolved(val bare: Fingerprint, val dark: Fingerprint, val light: Fingerprint)
|
||||
|
||||
private fun resolve(): Resolved {
|
||||
lateinit var bare: Fingerprint
|
||||
lateinit var dark: Fingerprint
|
||||
lateinit var light: Fingerprint
|
||||
composeRule.setContent {
|
||||
// No arguments — the call MainActivity makes, and the one nothing exercised.
|
||||
LibreMediaConverterTheme { bare = MaterialTheme.colorScheme.fingerprint() }
|
||||
LibreMediaConverterTheme(darkTheme = true) { dark = MaterialTheme.colorScheme.fingerprint() }
|
||||
LibreMediaConverterTheme(darkTheme = false) { light = MaterialTheme.colorScheme.fingerprint() }
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
return Resolved(bare, dark, light)
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,10 @@ import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.HardwareTranscoder
|
||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.DeviceCodecs
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
@@ -129,6 +131,40 @@ class ProgressNotificationTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same plumbing on the engine most conversions actually use, which had none.
|
||||
*
|
||||
* `ConversionWorker.kt:208-210` is a second `onProgress` lambda at a second call site — the one
|
||||
* handed to `engine.transcode` — and it reported `ci == 0`. Every test above drives the FFmpeg
|
||||
* path; `HardwareFallbackTest` reaches `runMedia3OrFallBack` but its recording transcoder
|
||||
* records the call and never invokes the callback it was given. So the two engines' progress
|
||||
* wiring was one tested and one not, and the untested one is the default: `ConversionRouter`
|
||||
* sends everything it can to Media3.
|
||||
*
|
||||
* `AUTO` with a real H.264 probe, because `FORCE_SOFTWARE` is precisely what keeps the other
|
||||
* tests out of this branch. The probe and the permissive codec profile are what let the router
|
||||
* choose Media3 at all — `InputProbe()` reports `UNPARSEABLE`, which routes straight to FFmpeg.
|
||||
*
|
||||
* Asserted on the *percentage*, not merely on an update having happened: `publishProgress`
|
||||
* takes a display name and a percent, and replacing the percent with a constant compiles.
|
||||
*/
|
||||
@Test
|
||||
fun `progress from the hardware engine reaches WorkManager the same way FFmpeg's does`() {
|
||||
ConversionDependencies.probe = { _, _ -> H264_SOURCE }
|
||||
val reporting = ReportingHardwareTranscoder { onProgress -> onProgress(PERCENT) }
|
||||
ConversionDependencies.hardware = { reporting }
|
||||
|
||||
runBlocking { workerReporting(EnginePreference.AUTO) { }.doWork() }
|
||||
|
||||
assertEquals("the job must have gone to the hardware engine", 1, reporting.attempts)
|
||||
val progressUpdates = updater.infos.drop(1)
|
||||
assertEquals("one throttled progress update expected", 1, progressUpdates.size)
|
||||
assertEquals(
|
||||
PERCENT,
|
||||
progressUpdates.single().notification.extras.getInt(Notification.EXTRA_PROGRESS),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A worker routed to the software engine, whose engine is [report] and a written output.
|
||||
*
|
||||
@@ -137,7 +173,10 @@ class ProgressNotificationTest {
|
||||
* bridge, which is native. [report] is handed the worker's own progress callback, and runs with
|
||||
* the worker as its receiver so a test can stop it mid-transcode.
|
||||
*/
|
||||
private fun workerReporting(report: ConversionWorker.((Int) -> Unit) -> Unit): ConversionWorker {
|
||||
private fun workerReporting(
|
||||
enginePreference: EnginePreference = EnginePreference.FORCE_SOFTWARE,
|
||||
report: ConversionWorker.((Int) -> Unit) -> Unit,
|
||||
): ConversionWorker {
|
||||
val worker = TestListenableWorkerBuilder<ConversionWorker>(
|
||||
context = app,
|
||||
inputData = workDataOf(
|
||||
@@ -147,7 +186,7 @@ class ProgressNotificationTest {
|
||||
ConversionWorker.KEY_CONTAINER to SPEC.container.name,
|
||||
ConversionWorker.KEY_VIDEO_CODEC to SPEC.videoCodec.name,
|
||||
ConversionWorker.KEY_AUDIO_CODEC to SPEC.audioCodec.name,
|
||||
ConversionWorker.KEY_ENGINE_PREFERENCE to EnginePreference.FORCE_SOFTWARE.name,
|
||||
ConversionWorker.KEY_ENGINE_PREFERENCE to enginePreference.name,
|
||||
),
|
||||
runAttemptCount = 0,
|
||||
).setId(JOB_ID)
|
||||
@@ -171,6 +210,17 @@ class ProgressNotificationTest {
|
||||
const val TICKS = 50
|
||||
val SPEC = OutputFormat.MP4_H265.spec
|
||||
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000021")
|
||||
|
||||
/**
|
||||
* A probe the router can actually route. `InputProbe()` reports `UNPARSEABLE`, which
|
||||
* `PERMISSIVE.canDecode` refuses, so every job would reach FFmpeg with no test saying why.
|
||||
*/
|
||||
val H264_SOURCE = InputProbe(
|
||||
videoCodec = "h264",
|
||||
audioCodec = "aac",
|
||||
container = Container.MP4,
|
||||
durationMs = 1_000,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,3 +261,22 @@ private class ReportingTranscoder(private val report: ((Int) -> Unit) -> Unit) :
|
||||
const val OUTPUT_BYTES = 512
|
||||
}
|
||||
}
|
||||
|
||||
/** A hardware engine that reports whatever [report] wants reported, then writes an output. */
|
||||
@UnstableApi
|
||||
private class ReportingHardwareTranscoder(private val report: ((Int) -> Unit) -> Unit) : HardwareTranscoder {
|
||||
|
||||
var attempts = 0
|
||||
|
||||
override suspend fun transcode(input: Uri, output: File, request: ConversionRequest, onProgress: (Int) -> Unit) {
|
||||
attempts++
|
||||
report(onProgress)
|
||||
output.writeBytes(ByteArray(OUTPUT_BYTES))
|
||||
}
|
||||
|
||||
override fun close() = Unit
|
||||
|
||||
private companion object {
|
||||
const val OUTPUT_BYTES = 16
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user