The FFprobe codec vocabulary is written out in at least four places and none of them had a test. Two had already drifted apart. `x264`, `hev1`, `x265` and `vp09` resolved in `CodecNames.videoFromName` and returned null from `AndroidDeviceCodecs.mimeForCodecName`, so the app identified the codec for the source card and for routing and then ran the device capability check blind on the same string; `mpeg4` ran the other way and rendered as a raw name. Nothing could notice, and the reason is structural: a `when` cannot be enumerated, so no test can ask one table what the other one knows. Both are maps now, for that reason alone, and `CodecVocabularyTest` walks the two key sets. A name added to -- or removed from -- one side alone fails the build. The one legitimate asymmetry is listed rather than implied: `mpeg4` is decodable input with no `VideoCodec` to name it, so `CodecNames` is right not to carry it. That list is itself checked, because otherwise it is an escape hatch -- any future divergence could be waved through by adding the name to it, and adding `x265` to it now fails. THIS CHANGES BEHAVIOUR for `x264`, `hev1`, `x265` and `vp09`. A null from `mimeForCodecName` means "unknown to us: assume the platform can handle it and let a failed export trigger the FFmpeg fallback", which is the right policy for a name nobody recognises and the wrong one for a name recognised one file over. A device without the matching decoder now sends those four to FFmpeg up front instead of spending a doomed hardware attempt to discover it. No input loses hardware it could have used: each alias resolves to the MIME its canonical spelling already resolved to, so a device that has the decoder still answers true. `ConversionRouterTest` still passes and that is not evidence either way -- every `canDecode` in it is a hand-written stub that never reaches this table. #74 is the same family one level down. `describeVideo` answered "Unrecognised" for `InputProbe.UNPARSEABLE` and `describeAudio` had no such arm, so an unparseable audio codec would have fallen through to `?: name` -- and the sentinel opens with a NUL, so the source-info card would have rendered a `Text` beginning with U+0000. The two now share one body, which is what stops the next arm being added to one side only. Two corrections to that ticket, taken from the file rather than from the ticket, since it warns about exactly this: - It quotes `audioFromName` as opening with `null, InputProbe.UNPARSEABLE -> null`. It did not; it opened with `null -> null` and the sentinel reached `else`. Naming the sentinel in the shared lookup therefore changes no answer and is documentation, not the fix. - It says `describeVideo`'s arm has no test of its own. It did -- `descriptions stay readable for unknown and missing codecs` asserts it -- so deleting the shared arm now reddens three tests across both sides, not one. Mutations run, each on the full 386-test suite: add "avc3" to CodecNames only -> CodecVocabularyTest red on two counts, CodecNamesTest green: 8 tests, 0 failures, which is the ticket's point about per-table arm tests delete the UNPARSEABLE arm -> CodecNamesTest red on three, one of them quoting the NUL back add "x265" to DECODE_ONLY_NAMES -> CodecVocabularyTest red on the escape hatch delete "vp09" from the MIME map -> CodecVocabularyTest red on three, which is the state this commit is fixing Audio is not cross-checked, and that is a gap rather than a decision: the device capability check is video-only, so this module has no second audio table to compare `AUDIO_ALIASES` against. `Media3Engine.audioMimeTypeFor` is the other half and belongs to #85. `MediaProbe.shortName` (#84) is the fourth table and is untouched here for the same reason. Closes #87. Closes #74. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
155 lines
7.6 KiB
Kotlin
155 lines
7.6 KiB
Kotlin
package org.libremediaconverter.codec
|
|
|
|
import android.media.MediaCodecList
|
|
import android.media.MediaFormat
|
|
import android.util.Log
|
|
import org.libremediaconverter.model.DeviceCodecs
|
|
import org.libremediaconverter.model.InputProbe
|
|
import org.libremediaconverter.model.VideoCodec
|
|
|
|
/**
|
|
* What this device's codec hardware can actually do.
|
|
*
|
|
* Enumerated once and cached: MediaCodecList is not cheap, and the answer cannot
|
|
* change while the process is alive.
|
|
*
|
|
* Two details that are easy to get wrong:
|
|
*
|
|
* - Devices expose **aliases** for the same underlying codec, so the list must be
|
|
* deduplicated by canonical name or capabilities get counted several times.
|
|
* - `isHardwareAccelerated()` is declared by the vendor and, in the platform's own
|
|
* 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.
|
|
*/
|
|
class AndroidDeviceCodecs private constructor(
|
|
private val hardwareEncodeMimes: Set<String>,
|
|
private val decodeMimes: Set<String>,
|
|
) : DeviceCodecs {
|
|
|
|
override fun canEncode(codec: VideoCodec): Boolean = mimeFor(codec)?.let { it in hardwareEncodeMimes } ?: true
|
|
|
|
override fun canDecode(codecName: String): Boolean {
|
|
// The platform already failed to parse this input, so there is nothing to
|
|
// decode with. Send it to FFmpeg rather than letting Media3 fail later.
|
|
if (codecName == InputProbe.UNPARSEABLE) return false
|
|
return mimeForCodecName(codecName)?.let { it in decodeMimes } ?: true
|
|
}
|
|
|
|
fun hardwareEncoders(): Set<String> = hardwareEncodeMimes
|
|
|
|
companion object {
|
|
private const val TAG = "AndroidDeviceCodecs"
|
|
|
|
@Volatile
|
|
private var cached: AndroidDeviceCodecs? = null
|
|
|
|
fun get(): AndroidDeviceCodecs = cached ?: synchronized(this) { cached ?: probe().also { cached = it } }
|
|
|
|
private fun probe(): AndroidDeviceCodecs {
|
|
val encoders = mutableSetOf<String>()
|
|
val decoders = mutableSetOf<String>()
|
|
val seen = mutableSetOf<String>()
|
|
|
|
runCatching {
|
|
MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.forEach { info ->
|
|
// 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
|
|
|
|
info.supportedTypes.forEach { mime ->
|
|
if (!mime.startsWith("video/")) return@forEach
|
|
if (info.isEncoder) {
|
|
if (info.isHardwareAccelerated && !info.isSoftwareOnly) {
|
|
encoders += mime
|
|
}
|
|
} else {
|
|
decoders += mime
|
|
}
|
|
}
|
|
}
|
|
}.onFailure { Log.w(TAG, "Codec enumeration failed; assuming permissive.", it) }
|
|
|
|
Log.i(TAG, "Hardware video encoders: $encoders")
|
|
return AndroidDeviceCodecs(encoders, decoders)
|
|
}
|
|
|
|
/**
|
|
* `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.
|
|
* The JVM test source set is a friend of `main`, so this stays invisible outside the
|
|
* module — the precedent is `MainActivity`'s `Destination`.
|
|
*/
|
|
internal fun mimeFor(codec: VideoCodec): String? = when (codec) {
|
|
VideoCodec.H264 -> MediaFormat.MIMETYPE_VIDEO_AVC
|
|
VideoCodec.H265 -> MediaFormat.MIMETYPE_VIDEO_HEVC
|
|
VideoCodec.VP8 -> MediaFormat.MIMETYPE_VIDEO_VP8
|
|
VideoCodec.VP9 -> MediaFormat.MIMETYPE_VIDEO_VP9
|
|
VideoCodec.AV1 -> MediaFormat.MIMETYPE_VIDEO_AV1
|
|
// Nothing is encoded for either, so there is no encoder to look for. Returning null
|
|
// makes canEncode answer true, which is the right answer: a copied or absent track
|
|
// places no demand on the hardware.
|
|
VideoCodec.COPY, VideoCodec.NONE -> null
|
|
}
|
|
|
|
/**
|
|
* FFprobe-style codec names, and the MediaFormat MIME type each one asks about.
|
|
*
|
|
* This is the same vocabulary `CodecNames.VIDEO_ALIASES` holds, written out a second time
|
|
* because this side has to answer in platform MIME types and `model` does not depend on
|
|
* Android. Two copies of one vocabulary drift, and these had: `x264`, `hev1`, `x265` and
|
|
* `vp09` resolved for display and routing and fell through to null here, so the app ran
|
|
* the capability check blind on inputs it had already identified (#87). They are listed
|
|
* now, which **changes behaviour** for those four names — see [mimeForCodecName].
|
|
*
|
|
* A map rather than a `when` because a `when` cannot be enumerated, and `CodecVocabularyTest`
|
|
* has to walk both key sets to notice the next divergence.
|
|
*/
|
|
internal val NAME_TO_MIME: Map<String, String> = mapOf(
|
|
"h264" to MediaFormat.MIMETYPE_VIDEO_AVC,
|
|
"avc" to MediaFormat.MIMETYPE_VIDEO_AVC,
|
|
"avc1" to MediaFormat.MIMETYPE_VIDEO_AVC,
|
|
"x264" to MediaFormat.MIMETYPE_VIDEO_AVC,
|
|
"hevc" to MediaFormat.MIMETYPE_VIDEO_HEVC,
|
|
"h265" to MediaFormat.MIMETYPE_VIDEO_HEVC,
|
|
"hvc1" to MediaFormat.MIMETYPE_VIDEO_HEVC,
|
|
"hev1" to MediaFormat.MIMETYPE_VIDEO_HEVC,
|
|
"x265" to MediaFormat.MIMETYPE_VIDEO_HEVC,
|
|
"vp8" to MediaFormat.MIMETYPE_VIDEO_VP8,
|
|
"vp9" to MediaFormat.MIMETYPE_VIDEO_VP9,
|
|
"vp09" to MediaFormat.MIMETYPE_VIDEO_VP9,
|
|
"av1" to MediaFormat.MIMETYPE_VIDEO_AV1,
|
|
"av01" to MediaFormat.MIMETYPE_VIDEO_AV1,
|
|
"mpeg4" to MediaFormat.MIMETYPE_VIDEO_MPEG4,
|
|
)
|
|
|
|
/**
|
|
* The names in [NAME_TO_MIME] that no [VideoCodec] member spells, and why.
|
|
*
|
|
* MPEG-4 Part 2 is decodable input the app never targets, so there is no enum for it and
|
|
* `CodecNames` is right not to carry it. That makes it the one place the two tables
|
|
* legitimately differ. It is listed rather than implied so the cross-check can tell a
|
|
* documented asymmetry from a fresh drift — and so the list itself is checked: a name here
|
|
* that `CodecNames` does resolve is a divergence being waved through, and the test fails on
|
|
* it.
|
|
*/
|
|
internal val DECODE_ONLY_NAMES: Set<String> = setOf("mpeg4")
|
|
|
|
/**
|
|
* Maps an FFprobe-style codec name onto a MediaFormat MIME type.
|
|
*
|
|
* Null keeps its documented meaning — unknown to us: assume the platform can handle it and
|
|
* let a failed export trigger the FFmpeg fallback, rather than pre-emptively refusing
|
|
* hardware. What changed with #87 is which names are unknown. Four that FFmpeg genuinely
|
|
* emits used to land here and be treated as unknown while the rest of the app knew exactly
|
|
* what they were; a device without the matching decoder now routes them to FFmpeg up front
|
|
* instead of spending a doomed hardware attempt to find out.
|
|
*/
|
|
internal fun mimeForCodecName(name: String): String? = NAME_TO_MIME[name.lowercase()]
|
|
|
|
/** Test seam: lets a test build a probe from explicit sets, on a device or on the JVM. */
|
|
fun forTesting(encoders: Set<String>, decoders: Set<String>) = AndroidDeviceCodecs(encoders, decoders)
|
|
}
|
|
}
|