Merge branch 'main' into fix/probe-dispatcher-seam
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,31 +138,6 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Media3 encodes only H.264 and H.265 of the codecs this app offers.
|
||||
*
|
||||
* VP8/VP9/AV1 targets never reach here — the router sends them to FFmpeg because
|
||||
* `Transformer.setVideoMimeType` rejects them — so anything unexpected returns null and lets
|
||||
* Transformer pick, rather than silently substituting H.265 the way the old mapping did.
|
||||
*/
|
||||
private fun videoMimeTypeFor(codec: VideoCodec): String? = when (codec) {
|
||||
VideoCodec.H264 -> MimeTypes.VIDEO_H264
|
||||
VideoCodec.H265 -> MimeTypes.VIDEO_H265
|
||||
// Never reached: only an Encode plan consults this, and COPY/NONE are not Encode.
|
||||
VideoCodec.COPY, VideoCodec.NONE -> null
|
||||
VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1 -> null
|
||||
}
|
||||
|
||||
private fun audioMimeTypeFor(codec: AudioCodec): String? = when (codec) {
|
||||
AudioCodec.AAC -> MimeTypes.AUDIO_AAC
|
||||
AudioCodec.OPUS -> MimeTypes.AUDIO_OPUS
|
||||
AudioCodec.VORBIS -> MimeTypes.AUDIO_VORBIS
|
||||
AudioCodec.PCM -> MimeTypes.AUDIO_RAW
|
||||
AudioCodec.COPY, AudioCodec.NONE -> null
|
||||
// MP3 and FLAC have no Android encoder; the router routes them to FFmpeg.
|
||||
AudioCodec.MP3, AudioCodec.FLAC -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls export progress on the Transformer's own thread.
|
||||
*
|
||||
@@ -192,7 +167,57 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
|
||||
thread.quitSafely()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* The progress interval, and the two enum-to-MIME tables.
|
||||
*
|
||||
* The tables are pure functions of a codec enum, so they sit here rather than on the instance:
|
||||
* a JVM test can then exercise every arm without constructing an engine, which would start a
|
||||
* real [HandlerThread] to answer a lookup. `internal` rather than `private` for the reason
|
||||
* `MainActivity`'s `Destination` records — the JVM test source set is a friend of `main`, so
|
||||
* these stay invisible to anything outside the module.
|
||||
*/
|
||||
internal companion object {
|
||||
const val PROGRESS_INTERVAL_MS = 250L
|
||||
|
||||
/**
|
||||
* Media3 encodes only H.264 and H.265 of the codecs this app offers.
|
||||
*
|
||||
* VP8/VP9/AV1 targets never reach here — the router sends them to FFmpeg because
|
||||
* `Transformer.setVideoMimeType` rejects them — so anything unexpected returns null and
|
||||
* lets Transformer pick, rather than silently substituting H.265 as the old mapping did.
|
||||
*/
|
||||
internal fun videoMimeTypeFor(codec: VideoCodec): String? = when (codec) {
|
||||
VideoCodec.H264 -> MimeTypes.VIDEO_H264
|
||||
VideoCodec.H265 -> MimeTypes.VIDEO_H265
|
||||
// Never reached, and no longer only asserted: `Media3EngineMimeTypesTest` drives
|
||||
// `CopyPlanner` over every spec it can be handed and shows that no Encode plan carries
|
||||
// either, which is what turns "COPY/NONE are not Encode" into a checked claim.
|
||||
VideoCodec.COPY, VideoCodec.NONE -> null
|
||||
VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1 -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Media3 encodes AAC, Opus and PCM. Three arms below are dead, not two.
|
||||
*
|
||||
* The comment this replaces named MP3 and FLAC as the exceptions, which reads as though
|
||||
* every other arm were live. **Vorbis is not.** A single router rule diverts every audio
|
||||
* codec outside {AAC, Opus, PCM} to FFmpeg, and Vorbis is outside it, so
|
||||
* `VORBIS -> AUDIO_VORBIS` names a MIME type Transformer is never actually asked for.
|
||||
*
|
||||
* The arm stays because the mapping is correct — deleting a right answer out of
|
||||
* unreachable code buys nothing — but it is an entry waiting on a routing change rather
|
||||
* than a live one. `Media3EngineMimeTypesTest` routes all six encodable codecs and asserts
|
||||
* which three arrive, so if that set moves, the disagreement fails rather than surprises.
|
||||
*/
|
||||
internal fun audioMimeTypeFor(codec: AudioCodec): String? = when (codec) {
|
||||
AudioCodec.AAC -> MimeTypes.AUDIO_AAC
|
||||
AudioCodec.OPUS -> MimeTypes.AUDIO_OPUS
|
||||
AudioCodec.VORBIS -> MimeTypes.AUDIO_VORBIS
|
||||
AudioCodec.PCM -> MimeTypes.AUDIO_RAW
|
||||
AudioCodec.COPY, AudioCodec.NONE -> null
|
||||
// MP3 and FLAC have no Android encoder at any API level, so the router sends them to
|
||||
// FFmpeg before an encoder is ever asked for.
|
||||
AudioCodec.MP3, AudioCodec.FLAC -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,8 +242,20 @@ object MediaProbe {
|
||||
else -> Container.MKV
|
||||
}
|
||||
|
||||
/** FFprobe describes still images through the image demuxers rather than a media container. */
|
||||
private fun isImageFormat(formatName: String): Boolean {
|
||||
/**
|
||||
* FFprobe describes still images through the image demuxers rather than a media container.
|
||||
*
|
||||
* The two halves of the rule are not interchangeable. `image2` is a whole name — what FFprobe
|
||||
* reports for a numbered image sequence — while `_pipe` has to be a *suffix* test, because the
|
||||
* piped demuxers are named one per image codec: `png_pipe`, `jpeg_pipe`, `webp_pipe`, and
|
||||
* thirty more. Relaxing that suffix to a substring would swallow `yuv4mpegpipe`, which is raw
|
||||
* video, and `classify` checks this before anything else — so a false positive makes the
|
||||
* source-info card describe a video as an image.
|
||||
*
|
||||
* `internal` so the unit tests can name both halves; the JVM test source set is a friend of
|
||||
* `main`, so this stays invisible outside the module.
|
||||
*/
|
||||
internal fun isImageFormat(formatName: String): Boolean {
|
||||
val names = formatName.split(',').map { it.trim().lowercase() }
|
||||
return names.any { it == "image2" || it.endsWith("_pipe") }
|
||||
}
|
||||
@@ -284,11 +296,35 @@ object MediaProbe {
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaFormat.intOr(key: String, fallback: Int = 0): Int =
|
||||
/**
|
||||
* One track property as an Int, or [fallback] when the format has no Int to give.
|
||||
*
|
||||
* `containsKey` alone is not enough, because `MediaFormat` is a heterogeneous map: a key it
|
||||
* holds as a Float answers `getInteger` with a `ClassCastException` rather than a coercion, and
|
||||
* `KEY_FRAME_RATE` — which [probeForConcat] reads — is legitimately set either way. The
|
||||
* `runCatching` is therefore load-bearing rather than defensive. Without it a single
|
||||
* oddly-typed field throws past the whole track loop, and the catch there answers with an empty
|
||||
* [ConcatInput], discarding the codec and dimensions that had already been read.
|
||||
*
|
||||
* `internal` for the unit tests, as [shortName].
|
||||
*/
|
||||
internal fun MediaFormat.intOr(key: String, fallback: Int = 0): Int =
|
||||
if (containsKey(key)) runCatching { getInteger(key) }.getOrDefault(fallback) else fallback
|
||||
|
||||
/** MediaFormat MIME -> the short codec names the router and FFmpeg both speak. */
|
||||
private fun shortName(mime: String): String = when (mime) {
|
||||
/**
|
||||
* MediaFormat MIME -> the short codec names the router and FFmpeg both speak.
|
||||
*
|
||||
* A lookup table over platform constants is the shape that rots quietly. Most of these arms are
|
||||
* translations rather than trimming — `video/avc` is `h264`, `audio/mp4a-latm` is `aac`,
|
||||
* `video/x-vnd.on2.vp9` is `vp9` — so a dropped arm does not fail. It falls through to
|
||||
* `substringAfter('/')` and reports a different, plausible-looking string that
|
||||
* `CodecNames` may or may not still recognise, and an unrecognised codec is how a
|
||||
* stream-copyable file quietly becomes a re-encode.
|
||||
*
|
||||
* `internal` so the unit tests can name every arm; the JVM test source set is a friend of
|
||||
* `main`, so this stays invisible outside the module.
|
||||
*/
|
||||
internal fun shortName(mime: String): String = when (mime) {
|
||||
MediaFormat.MIMETYPE_VIDEO_AVC -> "h264"
|
||||
MediaFormat.MIMETYPE_VIDEO_HEVC -> "hevc"
|
||||
MediaFormat.MIMETYPE_VIDEO_VP8 -> "vp8"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user