Files
LibreMediaConverter/app/src/main/java/org/libremediaconverter/model/CodecNames.kt
T
JMR-devandClaude Opus 5 7f951baf8f Make the two codec tables answer for each other, and stop describeAudio printing a NUL
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>
2026-08-24 22:44:55 -05:00

112 lines
5.1 KiB
Kotlin

package org.libremediaconverter.model
/**
* Maps the short codec names probing produces onto the app's codec enums.
*
* Three vocabularies meet here: `MediaExtractor` MIME types (normalised to short names by
* `MediaProbe.shortName`), FFprobe's `codec_name`, and this app's enums. Stream copy needs the
* round trip — "the source says `hevc`; is that the same thing as [VideoCodec.H265], and can the
* target container hold it?" — so the mapping has to live somewhere both [CopyPlanner] and
* [ContainerCapabilities] can reach, and somewhere a JVM test can exercise it.
*
* An unrecognised name returns null. That is deliberately not "no match": a copy planner that
* treated unknown as compatible would stream-copy a codec into a container that cannot hold it,
* and the failure would land on the user as a file that will not play.
*/
object CodecNames {
/**
* 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,
)
/**
* 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 = 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 -> label(name) ?: name
}
}