S1 (#141): cut the track walk into a pure seam, and test the matrix

#84 closed by classifying probeWithExtractor and probeForConcat as
device-bound and explicitly not a gap. That was right about FFprobe and
right about the measurement boundary, and wrong that these are only
orchestration. The track walk is a branch matrix, and androidTest reaches
it only through whatever the committed fixtures happen to contain -- so
none of its rules is *chosen* by any test there.

#133 offered two ways to reach it: drive ShadowMediaExtractor, or cut the
loop into a pure function. Taking the second, which is the pattern
CLAUDE.md names and work/FailureOutcome.kt documents. extractedFrom and
concatInputFrom take List<MediaFormat>; what is left needing a device --
setDataSource, getTrackFormat, release -- is one three-line extension
function, which is the thin edge androidTest should be covering.

The two are deliberately not merged despite the overlap. One reads
duration and not frame rate; the other reads frame rate and not duration.
A merged version would compute both for every caller, and ConcatPlanner
treats an unknown frame rate as "cannot prove a match" -- so a field the
join flow does not need must not start arriving as a number.

Eleven tests over cases no fixture provides: two video tracks, two audio
tracks, audio outlasting video, a track with no KEY_DURATION, audio
declared before video, a subtitle track, and no tracks at all.

Six mutations, six red:

  last video track wins            first-video test
  last audio track wins            first-audio test
  duration = last rather than max  longest-track test
  drop the containsKey guard       six tests (getLong throws on a missing key)
  guess a frame rate of 30         no-frame-rate test
  join takes the last video track  join frame-rate test

MediaProbe's missed branches drop 91 -> 70; what is left is the FFprobe
half and the two catch arms, which are native and device-bound exactly as
#84 said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 22:51:10 -05:00
co-authored by Claude Opus 5
parent 099b7fd7c4
commit b2790e13d9
2 changed files with 312 additions and 43 deletions
@@ -92,7 +92,12 @@ object MediaProbe {
else -> InputKind.UNPARSEABLE
}
private class Extracted(
/**
* `internal` rather than `private` so [extractedFrom] can be named from a test. The JVM test
* source set is a friend of `main`, so this stays invisible outside the module — the precedent
* is `MainActivity`'s `Destination`, and [containerFrom] beside it.
*/
internal class Extracted(
val videoCodec: String?,
val audioCodec: String?,
val durationMs: Long,
@@ -100,33 +105,55 @@ object MediaProbe {
val height: Int,
)
/**
* What a set of track formats says about a file.
*
* Split out of [probeWithExtractor] so the rules below can be tested against tracks a test
* *chooses*, rather than against whatever the committed fixtures happen to contain. The device
* tests exercise this through real files; none of them can construct a two-video-track input,
* a track that omits its duration, or an audio-before-video ordering on purpose.
*
* Three rules live here, and each is a decision rather than plumbing:
*
* - **First track of a type wins.** `video == null` is the whole guard. A file with two video
* tracks must report the first, because that is the one an engine will transcode.
* - **Duration is the maximum across tracks**, not the first one found or the last. A file
* whose audio outlasts its video is ordinary, and reporting the video's length would cut the
* progress bar short.
* - **A track that omits `KEY_DURATION` contributes nothing** rather than zero. `MediaExtractor`
* omits it for plenty of real tracks — see `MediaProbeTrackFieldsTest` — and `maxOf` against a
* fabricated 0 would still be correct here, but reading a key that is absent is not.
*/
internal fun extractedFrom(formats: List<MediaFormat>): Extracted {
var video: String? = null
var audio: String? = null
var durationUs = 0L
var width = 0
var height = 0
for (format in formats) {
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
if (format.containsKey(MediaFormat.KEY_DURATION)) {
durationUs = maxOf(durationUs, format.getLong(MediaFormat.KEY_DURATION))
}
when {
mime.startsWith("video/") && video == null -> {
video = shortName(mime)
width = format.intOr(MediaFormat.KEY_WIDTH)
height = format.intOr(MediaFormat.KEY_HEIGHT)
}
mime.startsWith("audio/") && audio == null -> audio = shortName(mime)
}
}
return Extracted(video, audio, durationUs / US_PER_MS, width, height)
}
private fun probeWithExtractor(context: Context, uri: Uri): Extracted? {
val extractor = MediaExtractor()
return try {
extractor.setDataSource(context, uri, null)
var video: String? = null
var audio: String? = null
var durationUs = 0L
var width = 0
var height = 0
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
if (format.containsKey(MediaFormat.KEY_DURATION)) {
durationUs = maxOf(durationUs, format.getLong(MediaFormat.KEY_DURATION))
}
when {
mime.startsWith("video/") && video == null -> {
video = shortName(mime)
width = format.intOr(MediaFormat.KEY_WIDTH)
height = format.intOr(MediaFormat.KEY_HEIGHT)
}
mime.startsWith("audio/") && audio == null -> audio = shortName(mime)
}
}
Extracted(video, audio, durationUs / US_PER_MS, width, height)
extractedFrom(extractor.trackFormats())
} catch (e: Exception) {
Log.i(TAG, "Platform extractor could not read $uri.", e)
null
@@ -269,25 +296,7 @@ object MediaProbe {
val extractor = MediaExtractor()
return try {
extractor.setDataSource(context, uri, null)
var video: String? = null
var audio: String? = null
var width = 0
var height = 0
var fps = 0
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
if (mime.startsWith("video/") && video == null) {
video = shortName(mime)
width = format.intOr(MediaFormat.KEY_WIDTH)
height = format.intOr(MediaFormat.KEY_HEIGHT)
fps = format.intOr(MediaFormat.KEY_FRAME_RATE)
} else if (mime.startsWith("audio/") && audio == null) {
audio = shortName(mime)
}
}
ConcatInput(video, audio, width, height, fps)
concatInputFrom(extractor.trackFormats())
} catch (e: Exception) {
Log.i(TAG, "Could not probe $uri for concat; will re-encode.", e)
ConcatInput(null, null, 0, 0, 0)
@@ -296,6 +305,45 @@ object MediaProbe {
}
}
/**
* The join flow's read of the same track formats. See [extractedFrom] for why this is separate
* from the extractor.
*
* Deliberately **not** folded into [extractedFrom] despite the overlap. This one reads frame
* rate and does not read duration; that one reads duration and does not read frame rate. A
* merged version would have to compute both for every caller, and `ConcatPlanner` treats an
* unknown frame rate as "cannot prove a match" — so a field this flow does not need must not
* start arriving as a number.
*/
internal fun concatInputFrom(formats: List<MediaFormat>): ConcatInput {
var video: String? = null
var audio: String? = null
var width = 0
var height = 0
var fps = 0
for (format in formats) {
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
if (mime.startsWith("video/") && video == null) {
video = shortName(mime)
width = format.intOr(MediaFormat.KEY_WIDTH)
height = format.intOr(MediaFormat.KEY_HEIGHT)
fps = format.intOr(MediaFormat.KEY_FRAME_RATE)
} else if (mime.startsWith("audio/") && audio == null) {
audio = shortName(mime)
}
}
return ConcatInput(video, audio, width, height, fps)
}
/**
* Every track format this extractor holds, read once.
*
* The thin edge the two pure functions above leave behind: a `trackCount` and a
* `getTrackFormat` per index, which is the whole of what needs a real `MediaExtractor`.
*/
private fun MediaExtractor.trackFormats(): List<MediaFormat> = (0 until trackCount).map(::getTrackFormat)
/**
* One track property as an Int, or [fallback] when the format has no Int to give.
*