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:
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user