Merge branch 'main' into test/mediaprobe-pure-helpers

This commit is contained in:
2026-08-24 23:32:41 -05:00
6 changed files with 361 additions and 45 deletions
@@ -75,7 +75,13 @@ class AndroidDeviceCodecs private constructor(
return AndroidDeviceCodecs(encoders, decoders)
}
private fun mimeFor(codec: VideoCodec): String? = when (codec) {
/**
* `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
@@ -87,20 +93,62 @@ class AndroidDeviceCodecs private constructor(
VideoCodec.COPY, VideoCodec.NONE -> null
}
/** Maps an FFprobe-style codec name onto a MediaFormat MIME type. */
private fun mimeForCodecName(name: String): String? = when (name.lowercase()) {
"h264", "avc", "avc1" -> MediaFormat.MIMETYPE_VIDEO_AVC
"hevc", "h265", "hvc1" -> MediaFormat.MIMETYPE_VIDEO_HEVC
"vp8" -> MediaFormat.MIMETYPE_VIDEO_VP8
"vp9" -> MediaFormat.MIMETYPE_VIDEO_VP9
"av1", "av01" -> MediaFormat.MIMETYPE_VIDEO_AV1
"mpeg4" -> MediaFormat.MIMETYPE_VIDEO_MPEG4
// Unknown to us: assume the platform can handle it and let a failed export
// trigger the FFmpeg fallback, rather than pre-emptively refusing hardware.
else -> 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,
)
/** Test seam: lets instrumented tests build a probe from explicit sets. */
/**
* 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)
}
}
@@ -15,36 +15,97 @@ package org.libremediaconverter.model
*/
object CodecNames {
fun videoFromName(name: String?): VideoCodec? = when (name?.lowercase()) {
null, InputProbe.UNPARSEABLE -> null
"h264", "avc", "avc1", "x264" -> VideoCodec.H264
"hevc", "h265", "hvc1", "hev1", "x265" -> VideoCodec.H265
"vp8" -> VideoCodec.VP8
"vp9", "vp09" -> VideoCodec.VP9
"av1", "av01" -> VideoCodec.AV1
else -> null
}
/**
* The video vocabulary, as data rather than a `when`.
*
* This is not the only place the app spells these names. `AndroidDeviceCodecs` reads the same
* FFprobe strings to decide what the device can decode, and answers in platform MIME types,
* which `model` cannot name without depending on Android. The two copies drifted apart:
* `x264`, `hev1`, `x265` and `vp09` resolved here and returned null there, so the app
* identified the codec for display and routing and then ran the device check blind, attempting
* a hardware path it had enough information to skip (#87).
*
* The reason this is a map is that **a `when` cannot be enumerated**, so nothing could compare
* the two tables. `CodecVocabularyTest` walks both key sets, so a name added to or removed
* from one side alone now fails the build rather than waiting for a wasted transcode to show
* it.
*
* Keys are lowercase; [videoFromName] lowercases before looking one up.
*/
internal val VIDEO_ALIASES: Map<String, VideoCodec> = mapOf(
"h264" to VideoCodec.H264,
"avc" to VideoCodec.H264,
"avc1" to VideoCodec.H264,
"x264" to VideoCodec.H264,
"hevc" to VideoCodec.H265,
"h265" to VideoCodec.H265,
"hvc1" to VideoCodec.H265,
"hev1" to VideoCodec.H265,
"x265" to VideoCodec.H265,
"vp8" to VideoCodec.VP8,
"vp9" to VideoCodec.VP9,
"vp09" to VideoCodec.VP9,
"av1" to VideoCodec.AV1,
"av01" to VideoCodec.AV1,
)
fun audioFromName(name: String?): AudioCodec? = when (name?.lowercase()) {
null -> null
"aac", "mp4a", "aac_latm" -> AudioCodec.AAC
"opus" -> AudioCodec.OPUS
"vorbis" -> AudioCodec.VORBIS
"mp3", "mp3float", "mpga" -> AudioCodec.MP3
"flac" -> AudioCodec.FLAC
"pcm", "raw", "pcm_s16le", "pcm_s24le", "pcm_f32le" -> AudioCodec.PCM
else -> null
}
/**
* The audio vocabulary, data for the same reason.
*
* Nothing cross-checks this one yet, and that is a gap rather than a decision: the device
* capability check is video-only, so this module holds no second audio table to compare it
* against. `Media3Engine.audioMimeTypeFor` is the other half, and #85 owns that file.
*/
internal val AUDIO_ALIASES: Map<String, AudioCodec> = mapOf(
"aac" to AudioCodec.AAC,
"mp4a" to AudioCodec.AAC,
"aac_latm" to AudioCodec.AAC,
"opus" to AudioCodec.OPUS,
"vorbis" to AudioCodec.VORBIS,
"mp3" to AudioCodec.MP3,
"mp3float" to AudioCodec.MP3,
"mpga" to AudioCodec.MP3,
"flac" to AudioCodec.FLAC,
"pcm" to AudioCodec.PCM,
"raw" to AudioCodec.PCM,
"pcm_s16le" to AudioCodec.PCM,
"pcm_s24le" to AudioCodec.PCM,
"pcm_f32le" to AudioCodec.PCM,
)
fun videoFromName(name: String?): VideoCodec? = asCodecName(name)?.let(VIDEO_ALIASES::get)
fun audioFromName(name: String?): AudioCodec? = asCodecName(name)?.let(AUDIO_ALIASES::get)
/** Human-readable name for the source-info card. Falls back to the raw probe string. */
fun describeVideo(name: String?): String = when {
fun describeVideo(name: String?): String = describe(name) { videoFromName(it)?.label }
fun describeAudio(name: String?): String = describe(name) { audioFromName(it)?.label }
/**
* Lowercases a probe string, and answers null for the two inputs that are not codec names at
* all: absent, and the [InputProbe.UNPARSEABLE] sentinel.
*
* The sentinel would miss every key anyway, so naming it changes no answer. Naming it is still
* the point: `videoFromName` excluded it explicitly and `audioFromName` did not, which read as
* though the two disagreed about what the sentinel means — the same asymmetry as #74 one
* function further up.
*/
private fun asCodecName(name: String?): String? =
if (name == null || name == InputProbe.UNPARSEABLE) null else name.lowercase()
/**
* The shared body of [describeVideo] and [describeAudio].
*
* They are one function apiece over one vocabulary, and they had stopped matching:
* `describeVideo` answered "Unrecognised" for [InputProbe.UNPARSEABLE] and `describeAudio` fell
* through to `?: name` instead. The sentinel opens with a NUL, so that fallback would have put
* a U+0000 into a `Text` on the source-info card (#74). Sharing the arms is what stops the next
* one being added to one side only.
*/
private fun describe(name: String?, label: (String) -> String?): String = when {
name == null -> "Unknown"
name == InputProbe.UNPARSEABLE -> "Unrecognised"
else -> videoFromName(name)?.label ?: name
}
fun describeAudio(name: String?): String = when {
name == null -> "Unknown"
else -> audioFromName(name)?.label ?: name
else -> label(name) ?: name
}
}