fix(android): play MKV files with late track metadata
MKV files whose Tracks element follows media clusters could direct-play with audio but no video. Upgrade Media3 to 1.11.0 and cover the extractor regression. close #1947
This commit is contained in:
@@ -64,7 +64,7 @@ val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
|
||||
val mpvAar = "libmpv-release.aar"
|
||||
val mpvUrl = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
|
||||
|
||||
val media3Version = "1.10.1"
|
||||
val media3Version = "1.11.0"
|
||||
val mpvFfmpegVersion = "8.0.1"
|
||||
val mpvFfmpegSourceSha256 = "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41"
|
||||
val mpvFfmpegSourceUrl = "https://ffmpeg.org/releases/ffmpeg-$mpvFfmpegVersion.tar.xz"
|
||||
|
||||
@@ -166,16 +166,15 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
||||
enableFloatOutput: Boolean,
|
||||
enableAudioOutputPlaybackParams: Boolean
|
||||
): AudioSink {
|
||||
AudioTrackAudioOutputProvider.failOnSpuriousAudioTimestamp = false
|
||||
|
||||
// Media3 1.11's replacement is fixed at 500ms; preserve the dynamic 500–1000ms PCM policy.
|
||||
@Suppress("DEPRECATION")
|
||||
val bufferSizeProvider = DefaultAudioTrackBufferSizeProvider.Builder()
|
||||
.setMinPcmBufferDurationUs(500_000)
|
||||
.setMaxPcmBufferDurationUs(1_000_000)
|
||||
.setPcmBufferMultiplicationFactor(4)
|
||||
// media3 defaults passthrough to 250ms, which the AC3 factor doubles to 500ms — 40000
|
||||
// bytes at 640 kbps. Some HDMI routes reject a buffer that short outright and the only
|
||||
// retry media3 1.10.1 has is to keep halving it (#1790). Ask for a second up front;
|
||||
// upstream adopted the same 1s floor as its last-resort retry in #3207.
|
||||
// Media3 defaults passthrough to 250ms, which the AC3 factor doubles to 500ms. Some
|
||||
// HDMI routes reject a buffer that short (#1790). Ask for a second up front; Media3 1.11
|
||||
// now uses the same one-second floor for its last-resort retry (#3207).
|
||||
.setPassthroughBufferDurationUs(500_000)
|
||||
.build()
|
||||
|
||||
|
||||
@@ -165,7 +165,8 @@ internal class TrueHdCarrierSink(
|
||||
else -> defaultSink.getFormatSupport(format)
|
||||
}
|
||||
|
||||
override fun configure(inputFormat: Format, specifiedBufferSize: Int, outputChannels: IntArray?) {
|
||||
override fun configure(audioSinkConfig: AudioSink.AudioSinkConfig) {
|
||||
val inputFormat = audioSinkConfig.format
|
||||
val useCarrier = shouldUseCarrier(inputFormat)
|
||||
if (useCarrier != carrierActive) {
|
||||
log?.invoke(
|
||||
@@ -185,9 +186,14 @@ internal class TrueHdCarrierSink(
|
||||
discardCarrierState()
|
||||
|
||||
if (useCarrier) {
|
||||
carrierSink.configure(carrierFormat(), specifiedBufferSize, null)
|
||||
val carrierConfig = AudioSink.AudioSinkConfig.Builder(carrierFormat())
|
||||
.setPreferredBufferSizeOverride(audioSinkConfig.preferredBufferSizeOverride)
|
||||
.setTimeline(audioSinkConfig.timeline)
|
||||
.setMediaPeriodId(audioSinkConfig.mediaPeriodId)
|
||||
.build()
|
||||
carrierSink.configure(carrierConfig)
|
||||
} else {
|
||||
defaultSink.configure(inputFormat, specifiedBufferSize, outputChannels)
|
||||
defaultSink.configure(audioSinkConfig)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
@@ -8,12 +8,23 @@ import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.EOFException
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class BufferedTransformingTrackOutputTest {
|
||||
@Before
|
||||
fun enableMedia3TestBoundsChecks() {
|
||||
ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(true)
|
||||
}
|
||||
|
||||
@After
|
||||
fun resetMedia3TestBoundsChecks() {
|
||||
ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activeTransformBuffersChunksAndEmitsOneNormalizedSample() {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.DataReader
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.extractor.DefaultExtractorInput
|
||||
import androidx.media3.extractor.Extractor
|
||||
import androidx.media3.extractor.ExtractorInput
|
||||
import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.PositionHolder
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Regression coverage for Matroska files whose SeekHead references Tracks after the Clusters. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class MatroskaLateTracksTest {
|
||||
|
||||
private class CapturedTrack(val type: Int) : TrackOutput {
|
||||
var format: Format? = null
|
||||
var sampleCount = 0
|
||||
|
||||
override fun format(format: Format) {
|
||||
this.format = format
|
||||
}
|
||||
|
||||
override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int = input.read(ByteArray(length), 0, length)
|
||||
|
||||
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
|
||||
data.skipBytes(length)
|
||||
}
|
||||
|
||||
override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
|
||||
sampleCount++
|
||||
}
|
||||
}
|
||||
|
||||
private class CapturingExtractorOutput : ExtractorOutput {
|
||||
val tracks = mutableMapOf<Int, CapturedTrack>()
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { CapturedTrack(type) }
|
||||
override fun endTracks() = Unit
|
||||
override fun seekMap(seekMap: SeekMap) = Unit
|
||||
}
|
||||
|
||||
private class ByteArrayDataReader(private val data: ByteArray) : DataReader {
|
||||
var position = 0L
|
||||
|
||||
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
|
||||
if (position >= data.size) return C.RESULT_END_OF_INPUT
|
||||
val bytesRead = minOf(length, data.size - position.toInt())
|
||||
data.copyInto(buffer, offset, position.toInt(), position.toInt() + bytesRead)
|
||||
position += bytesRead
|
||||
return bytesRead
|
||||
}
|
||||
}
|
||||
|
||||
private fun fixtureData(): ByteArray = checkNotNull(javaClass.getResourceAsStream("/matroska_tracks_at_end.mkv")) {
|
||||
"fixture matroska_tracks_at_end.mkv missing from test resources"
|
||||
}.use { it.readBytes() }
|
||||
|
||||
private fun extractFixture(): CapturingExtractorOutput {
|
||||
val data = fixtureData()
|
||||
val assHandler = AssHandler()
|
||||
val extractor = ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler)
|
||||
val output = CapturingExtractorOutput()
|
||||
extractor.init(output)
|
||||
|
||||
val reader = ByteArrayDataReader(data)
|
||||
var input: ExtractorInput = DefaultExtractorInput(reader, 0, data.size.toLong())
|
||||
val seekPosition = PositionHolder()
|
||||
repeat(100_000) {
|
||||
when (extractor.read(input, seekPosition)) {
|
||||
Extractor.RESULT_END_OF_INPUT -> return output
|
||||
Extractor.RESULT_SEEK -> {
|
||||
reader.position = seekPosition.position
|
||||
input = DefaultExtractorInput(reader, seekPosition.position, data.size.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
error("extractor did not reach end of input")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractsSamplesWhenSeekHeadReferencesTracksAfterClusters() {
|
||||
val output = extractFixture()
|
||||
|
||||
assertEquals(2, output.tracks.size)
|
||||
assertEquals(setOf(C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_AUDIO), output.tracks.values.map { it.type }.toSet())
|
||||
output.tracks.values.forEach { track ->
|
||||
assertNotNull(track.format)
|
||||
assertTrue("expected extracted samples for track type ${track.type}", track.sampleCount > 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,12 @@ import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.common.util.Clock
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.analytics.PlayerId
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
@@ -44,6 +46,8 @@ class TrueHdCarrierSinkTest {
|
||||
.setSampleRate(sampleRate)
|
||||
.build()
|
||||
|
||||
private fun audioSinkConfig(format: Format = trueHdFormat()) = AudioSink.AudioSinkConfig.Builder(format).build()
|
||||
|
||||
private fun sink(
|
||||
carrier: FakeSink = FakeSink(),
|
||||
normal: FakeSink = FakeSink(),
|
||||
@@ -109,7 +113,7 @@ class TrueHdCarrierSinkTest {
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
val listener = RecordingSinkListener()
|
||||
carrierSink.setListener(listener)
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
assertTrue("carrier should be live before the speed change", carrierSink.isCarrierActive)
|
||||
|
||||
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
|
||||
@@ -130,7 +134,7 @@ class TrueHdCarrierSinkTest {
|
||||
val carrierSink = sink()
|
||||
val listener = RecordingSinkListener()
|
||||
carrierSink.setListener(listener)
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
|
||||
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
|
||||
carrierSink.setPlaybackParameters(PlaybackParameters(1f))
|
||||
@@ -145,7 +149,7 @@ class TrueHdCarrierSinkTest {
|
||||
val carrierSink = sink()
|
||||
val listener = RecordingSinkListener()
|
||||
carrierSink.setListener(listener)
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
|
||||
carrierSink.setPlaybackParameters(PlaybackParameters(1f))
|
||||
|
||||
@@ -163,7 +167,7 @@ class TrueHdCarrierSinkTest {
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
val listener = RecordingSinkListener()
|
||||
carrierSink.setListener(listener)
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
|
||||
val buffer = ByteBuffer.wrap(fortyFourFamilyAccessUnits())
|
||||
val accepted = carrierSink.handleBuffer(buffer, 0L, 1)
|
||||
@@ -188,7 +192,7 @@ class TrueHdCarrierSinkTest {
|
||||
fun theMismatchLatchSurvivesTheResetThatAccompaniesTheHandover() {
|
||||
val carrierSink = sink()
|
||||
carrierSink.setListener(RecordingSinkListener())
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
|
||||
|
||||
carrierSink.flush()
|
||||
@@ -206,7 +210,7 @@ class TrueHdCarrierSinkTest {
|
||||
fun releasingTheSinkClearsTheMismatchLatch() {
|
||||
val carrierSink = sink()
|
||||
carrierSink.setListener(RecordingSinkListener())
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
|
||||
|
||||
carrierSink.release()
|
||||
@@ -236,7 +240,7 @@ class TrueHdCarrierSinkTest {
|
||||
fun aNewMediaItemClearsTheMismatchLatch() {
|
||||
val carrierSink = sink()
|
||||
carrierSink.setListener(RecordingSinkListener())
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
|
||||
assertEquals(
|
||||
AudioSink.SINK_FORMAT_UNSUPPORTED,
|
||||
@@ -261,7 +265,7 @@ class TrueHdCarrierSinkTest {
|
||||
fun aLateMismatchFromThePreviousItemDoesNotPoisonTheNewOne() {
|
||||
val carrierSink = sink()
|
||||
carrierSink.setListener(RecordingSinkListener())
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
|
||||
// The new item starts before the outgoing stream's last buffer is handled.
|
||||
carrierSink.beginMediaItem()
|
||||
@@ -294,7 +298,7 @@ class TrueHdCarrierSinkTest {
|
||||
fun aRefusedBurstMidSampleLosesNoAccessUnits() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
|
||||
// Refuse every third burst once, so rejections land inside samples rather than between them.
|
||||
carrier.refuseEveryNth = 3
|
||||
@@ -309,7 +313,7 @@ class TrueHdCarrierSinkTest {
|
||||
fun theCarrierOutputMatchesTheGoldenStream() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
|
||||
assertArrayEquals(golden, feedWholeStream(carrierSink))
|
||||
}
|
||||
@@ -319,7 +323,7 @@ class TrueHdCarrierSinkTest {
|
||||
fun aPendingBurstIsPlacedBeforeMoreInputIsConsumed() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
carrier.refuseNext = true
|
||||
|
||||
val buffer = ByteBuffer.wrap(accessUnits)
|
||||
@@ -362,14 +366,22 @@ class TrueHdCarrierSinkTest {
|
||||
fun theCarrierDelegateIsConfiguredAsAFixedRatePcmCarrier() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
val inputConfig = AudioSink.AudioSinkConfig.Builder(trueHdFormat())
|
||||
.setPreferredBufferSizeOverride(4_096)
|
||||
.setTimeline(Timeline.EMPTY)
|
||||
.setMediaPeriodId(MediaSource.MediaPeriodId("period"))
|
||||
.build()
|
||||
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
carrierSink.configure(inputConfig)
|
||||
|
||||
val configured = checkNotNull(carrier.configuredFormat)
|
||||
assertEquals(MimeTypes.AUDIO_RAW, configured.sampleMimeType)
|
||||
assertEquals(C.ENCODING_PCM_16BIT, configured.pcmEncoding)
|
||||
assertEquals(TrueHdMatPacker.CARRIER_SAMPLE_RATE, configured.sampleRate)
|
||||
assertEquals(TrueHdMatPacker.CARRIER_CHANNEL_COUNT, configured.channelCount)
|
||||
val configured = checkNotNull(carrier.configuredConfig)
|
||||
assertEquals(inputConfig.preferredBufferSizeOverride, configured.preferredBufferSizeOverride)
|
||||
assertSame(inputConfig.timeline, configured.timeline)
|
||||
assertEquals(inputConfig.mediaPeriodId, configured.mediaPeriodId)
|
||||
assertEquals(MimeTypes.AUDIO_RAW, configured.format.sampleMimeType)
|
||||
assertEquals(C.ENCODING_PCM_16BIT, configured.format.pcmEncoding)
|
||||
assertEquals(TrueHdMatPacker.CARRIER_SAMPLE_RATE, configured.format.sampleRate)
|
||||
assertEquals(TrueHdMatPacker.CARRIER_CHANNEL_COUNT, configured.format.channelCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -378,11 +390,13 @@ class TrueHdCarrierSinkTest {
|
||||
val normal = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier, normal = normal)
|
||||
val ac3 = Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AC3).setSampleRate(48_000).build()
|
||||
val config = audioSinkConfig(ac3)
|
||||
|
||||
carrierSink.configure(ac3, 0, null)
|
||||
carrierSink.configure(config)
|
||||
|
||||
assertSame(ac3, normal.configuredFormat)
|
||||
assertEquals(null, carrier.configuredFormat)
|
||||
assertSame(config, normal.configuredConfig)
|
||||
assertSame(ac3, normal.configuredConfig?.format)
|
||||
assertEquals(null, carrier.configuredConfig)
|
||||
}
|
||||
|
||||
private fun feedWholeStream(carrierSink: TrueHdCarrierSink): ByteArray {
|
||||
@@ -416,7 +430,7 @@ class TrueHdCarrierSinkTest {
|
||||
/** Minimal AudioSink that records what it was handed and can apply back pressure. */
|
||||
private class FakeSink : AudioSink {
|
||||
val written = ByteArrayOutputStream()
|
||||
var configuredFormat: Format? = null
|
||||
var configuredConfig: AudioSink.AudioSinkConfig? = null
|
||||
var formatSupport: Int = AudioSink.SINK_FORMAT_UNSUPPORTED
|
||||
var lastVolume: Float = -1f
|
||||
var sessionId: Int = -1
|
||||
@@ -444,8 +458,8 @@ class TrueHdCarrierSinkTest {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun configure(inputFormat: Format, specifiedBufferSize: Int, outputChannels: IntArray?) {
|
||||
configuredFormat = inputFormat
|
||||
override fun configure(audioSinkConfig: AudioSink.AudioSinkConfig) {
|
||||
configuredConfig = audioSinkConfig
|
||||
}
|
||||
|
||||
override fun supportsFormat(format: Format) = formatSupport == AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY
|
||||
|
||||
+11
@@ -9,13 +9,24 @@ import androidx.media3.extractor.TrackOutput
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.zip.Deflater
|
||||
import java.util.zip.DeflaterOutputStream
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class ZlibInflatingTrackOutputTest {
|
||||
@Before
|
||||
fun enableMedia3TestBoundsChecks() {
|
||||
ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(true)
|
||||
}
|
||||
|
||||
@After
|
||||
fun resetMedia3TestBoundsChecks() {
|
||||
ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activeTransformInflatesChunkedSampleAndPreservesMetadata() {
|
||||
|
||||
Binary file not shown.
@@ -54,8 +54,8 @@ kotlin {
|
||||
dependencies {
|
||||
implementation("androidx.annotation:annotation:1.10.0")
|
||||
implementation("androidx.annotation:annotation-experimental:1.6.0")
|
||||
implementation("androidx.media3:media3-exoplayer:1.10.1")
|
||||
implementation("androidx.media3:media3-ui:1.10.1")
|
||||
implementation("androidx.media3:media3-exoplayer:1.11.0")
|
||||
implementation("androidx.media3:media3-ui:1.11.0")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user