Skip to content

Commit

Permalink
Ignore renderer errors from text/metadata tracks
Browse files Browse the repository at this point in the history
Before this change:

* With legacy subtitle decoding (at render time), load errors (e.g. HTTP
  404) would result playback completely failing, while parse errors
  (e.g. invalid  WebVTT data) would be silently ignored, so playback
  would continue without subtitles.
* With new subtitle decoding (at extraction time), both load and parse
  errors would result in playback completely failing.

This change means that now neither load nor parse errors in text or
metadata tracks stop playback from continuing. Instead the error'd track
is disabled until the end of the current period.

With new subtitle decoding, both load and parse errors happen during
loading/extraction, and so are emitted to the app via
`MediaSourceEventListener.onLoadError` and
`AnalyticsListener.onLoadError`. With legacy subtitle decoding, only
load errors are emitted via these listeners and parsing errors continue
to be silently ignored.

Issue: #1722
PiperOrigin-RevId: 686902979
  • Loading branch information
icbaker authored and copybara-github committed Oct 17, 2024
1 parent 191bc09 commit 49dec5d
Show file tree
Hide file tree
Showing 16 changed files with 3,200 additions and 2 deletions.
10 changes: 10 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@
subtitles (those added with
`MediaItem.LocalConfiguration.subtitleConfigurations`), which may appear
as duplicate load events emitted from `AnalyticsListener`.
* Prevent subtitle & metadata errors from completely stopping playback.
Instead the problematic track is disabled and playback of the remaining
tracks continues
([#1722](https://github.com/google/ExoPlayer/issues/1722)).
* In new subtitle handling (during extraction), associated parse (e.g.
invalid subtitle data) and load errors (e.g. HTTP 404) are emitted
via `onLoadError` callbacks.
* In legacy subtitle handling (during rendering), only associated load
errors are emitted via `onLoadError` callbacks while parse errors
are silently ignored (this is pre-existing behaviour).
* Transformer:
* Make setting the image duration using
`MediaItem.Builder.setImageDurationMs` mandatory for image export.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1149,7 +1149,7 @@ private void doSomeWork() throws ExoPlaybackException, IOException {
maybeTriggerOnRendererReadyChanged(/* rendererIndex= */ i, allowsPlayback);
renderersAllowPlayback = renderersAllowPlayback && allowsPlayback;
if (!allowsPlayback) {
renderer.maybeThrowStreamError();
maybeThrowRendererStreamError(/* rendererIndex= */ i);
}
}
} else {
Expand Down Expand Up @@ -1200,7 +1200,7 @@ && shouldTransitionToReadyState(renderersAllowPlayback)) {
for (int i = 0; i < renderers.length; i++) {
if (isRendererEnabled(renderers[i])
&& renderers[i].getStream() == playingPeriodHolder.sampleStreams[i]) {
renderers[i].maybeThrowStreamError();
maybeThrowRendererStreamError(/* rendererIndex= */ i);
}
}
if (!playbackInfo.isLoading
Expand Down Expand Up @@ -2940,6 +2940,46 @@ private boolean shouldPlayWhenReady() {
&& playbackInfo.playbackSuppressionReason == Player.PLAYBACK_SUPPRESSION_REASON_NONE;
}

private void maybeThrowRendererStreamError(int rendererIndex)
throws IOException, ExoPlaybackException {
Renderer renderer = renderers[rendererIndex];
try {
renderer.maybeThrowStreamError();
} catch (IOException | RuntimeException e) {
switch (renderer.getTrackType()) {
case C.TRACK_TYPE_TEXT:
case C.TRACK_TYPE_METADATA:
TrackSelectorResult currentTrackSelectorResult =
queue.getPlayingPeriod().getTrackSelectorResult();
Log.e(
TAG,
"Disabling track due to error: "
+ Format.toLogString(
currentTrackSelectorResult.selections[rendererIndex].getSelectedFormat()),
e);

TrackSelectorResult newTrackSelectorResult =
new TrackSelectorResult(
currentTrackSelectorResult.rendererConfigurations.clone(),
currentTrackSelectorResult.selections.clone(),
currentTrackSelectorResult.tracks,
currentTrackSelectorResult.info);
newTrackSelectorResult.rendererConfigurations[rendererIndex] = null;
newTrackSelectorResult.selections[rendererIndex] = null;
disableRenderer(rendererIndex);
queue
.getPlayingPeriod()
.applyTrackSelection(
newTrackSelectorResult,
playbackInfo.positionUs,
/* forceRecreateStreams= */ false);
break;
default:
throw e;
}
}
}

private static PositionUpdateForPlaylistChange resolvePositionForPlaylistChange(
Timeline timeline,
PlaybackInfo playbackInfo,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@
import androidx.media3.exoplayer.source.SampleStream.ReadDataResult;
import androidx.media3.extractor.text.CueDecoder;
import androidx.media3.extractor.text.CuesWithTiming;
import androidx.media3.extractor.text.Subtitle;
import androidx.media3.extractor.text.SubtitleDecoder;
import androidx.media3.extractor.text.SubtitleDecoderException;
import androidx.media3.extractor.text.SubtitleInputBuffer;
import androidx.media3.extractor.text.SubtitleOutputBuffer;
import com.google.common.collect.ImmutableList;
import java.io.IOException;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
Expand Down Expand Up @@ -122,6 +124,7 @@ public final class TextRenderer extends BaseRenderer implements Callback {
private long lastRendererPositionUs;
private long finalStreamEndPositionUs;
private boolean legacyDecodingEnabled;
@Nullable private IOException streamError;

/**
* @param output The output.
Expand Down Expand Up @@ -472,11 +475,40 @@ public boolean isEnded() {

@Override
public boolean isReady() {
if (streamFormat == null) {
return true;
}
if (streamError == null) {
try {
maybeThrowStreamError();
} catch (IOException e) {
streamError = e;
}
}

if (streamError != null) {
if (isCuesWithTiming(checkNotNull(streamFormat))) {
return checkNotNull(cuesResolver).getNextCueChangeTimeUs(lastRendererPositionUs)
!= C.TIME_END_OF_SOURCE;
} else {
if (outputStreamEnded
|| (inputStreamEnded
&& hasNoEventsAfter(subtitle, lastRendererPositionUs)
&& hasNoEventsAfter(nextSubtitle, lastRendererPositionUs)
&& nextSubtitleInputBuffer != null)) {
return false;
}
}
}
// Don't block playback whilst subtitles are loading.
// Note: To change this behavior, it will be necessary to consider [Internal: b/12949941].
return true;
}

private static boolean hasNoEventsAfter(@Nullable Subtitle subtitle, long timeUs) {
return subtitle == null || subtitle.getEventTime(subtitle.getEventTimeCount() - 1) <= timeUs;
}

private void releaseSubtitleBuffers() {
nextSubtitleInputBuffer = null;
nextSubtitleEventIndex = C.INDEX_UNSET;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.exoplayer.e2etest;

import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.run;
import static com.google.common.truth.Truth.assertThat;

import android.content.Context;
import android.graphics.SurfaceTexture;
import android.net.Uri;
import android.view.Surface;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.ParserException;
import androidx.media3.common.Player;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.analytics.AnalyticsListener;
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
import androidx.media3.exoplayer.source.LoadEventInfo;
import androidx.media3.exoplayer.source.MediaLoadData;
import androidx.media3.test.utils.CapturingRenderersFactory;
import androidx.media3.test.utils.DumpFileAsserts;
import androidx.media3.test.utils.FakeClock;
import androidx.media3.test.utils.ThrowingSubtitleParserFactory;
import androidx.media3.test.utils.robolectric.PlaybackOutput;
import androidx.media3.test.utils.robolectric.ShadowMediaCodecConfig;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

/** End-to-end tests of subtitle playback behaviour. */
@RunWith(AndroidJUnit4.class)
public class SubtitlePlaybackTest {

@Rule
public ShadowMediaCodecConfig mediaCodecConfig =
ShadowMediaCodecConfig.forAllSupportedMimeTypes();

@Test
public void sideloadedSubtitleLoadingError_playbackContinues_errorReportedToAnalyticsListener()
throws Exception {
Context applicationContext = ApplicationProvider.getApplicationContext();
CapturingRenderersFactory capturingRenderersFactory =
new CapturingRenderersFactory(applicationContext);
AtomicReference<LoadEventInfo> loadErrorEventInfo = new AtomicReference<>();
AnalyticsListener analyticsListener =
new AnalyticsListener() {
@Override
public void onLoadError(
EventTime eventTime,
LoadEventInfo loadEventInfo,
MediaLoadData mediaLoadData,
IOException error,
boolean wasCanceled) {
loadErrorEventInfo.set(loadEventInfo);
}
};
ExoPlayer player =
new ExoPlayer.Builder(applicationContext, capturingRenderersFactory)
.setClock(new FakeClock(/* isAutoAdvancing= */ true))
.build();
player.addAnalyticsListener(analyticsListener);
Surface surface = new Surface(new SurfaceTexture(/* texName= */ 1));
player.setVideoSurface(surface);
PlaybackOutput playbackOutput = PlaybackOutput.register(player, capturingRenderersFactory);
Uri notFoundSubtitleUri = Uri.parse("asset:///file/not/found");
MediaItem mediaItem =
new MediaItem.Builder()
.setUri("asset:///media/mp4/sample.mp4")
.setSubtitleConfigurations(
ImmutableList.of(
new MediaItem.SubtitleConfiguration.Builder(notFoundSubtitleUri)
.setMimeType(MimeTypes.TEXT_VTT)
.setLanguage("en")
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
.build()))
.build();

player.setMediaItem(mediaItem);
player.prepare();
run(player).ignoringNonFatalErrors().untilState(Player.STATE_READY);
run(player).untilLoadingIs(false);
player.play();
run(player).untilState(Player.STATE_ENDED);
player.release();
surface.release();

assertThat(loadErrorEventInfo.get().uri).isEqualTo(notFoundSubtitleUri);
// Assert the output is the same as playing the video without sideloaded subtitles.
DumpFileAsserts.assertOutput(
applicationContext, playbackOutput, "playbackdumps/mp4/sample.mp4.dump");
}

@Test
public void sideloadedSubtitleParsingError_playbackContinues_errorReportedToAnalyticsListener()
throws Exception {
Context applicationContext = ApplicationProvider.getApplicationContext();
CapturingRenderersFactory capturingRenderersFactory =
new CapturingRenderersFactory(applicationContext);
AtomicReference<LoadEventInfo> loadErrorEventInfo = new AtomicReference<>();
AtomicReference<IOException> loadError = new AtomicReference<>();
AnalyticsListener analyticsListener =
new AnalyticsListener() {
@Override
public void onLoadError(
EventTime eventTime,
LoadEventInfo loadEventInfo,
MediaLoadData mediaLoadData,
IOException error,
boolean wasCanceled) {
loadErrorEventInfo.set(loadEventInfo);
loadError.set(error);
}
};
ExoPlayer player =
new ExoPlayer.Builder(applicationContext, capturingRenderersFactory)
.setClock(new FakeClock(/* isAutoAdvancing= */ true))
.setMediaSourceFactory(
new DefaultMediaSourceFactory(applicationContext)
.setSubtitleParserFactory(
new ThrowingSubtitleParserFactory(
() -> new IllegalStateException("test subtitle parsing error"))))
.build();
player.addAnalyticsListener(analyticsListener);
Surface surface = new Surface(new SurfaceTexture(/* texName= */ 1));
player.setVideoSurface(surface);
PlaybackOutput playbackOutput = PlaybackOutput.register(player, capturingRenderersFactory);
MediaItem mediaItem =
new MediaItem.Builder()
.setUri("asset:///media/mp4/sample.mp4")
.setSubtitleConfigurations(
ImmutableList.of(
new MediaItem.SubtitleConfiguration.Builder(
Uri.parse("asset:///media/webvtt/typical"))
.setMimeType(MimeTypes.TEXT_VTT)
.setLanguage("en")
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
.build()))
.build();

player.setMediaItem(mediaItem);
player.prepare();
run(player).ignoringNonFatalErrors().untilState(Player.STATE_READY);
run(player).untilLoadingIs(false);
player.play();
run(player).untilState(Player.STATE_ENDED);
player.release();
surface.release();

assertThat(loadError.get()).isInstanceOf(ParserException.class);
assertThat(loadError.get())
.hasCauseThat()
.hasMessageThat()
.contains("test subtitle parsing error");
DumpFileAsserts.assertOutput(
applicationContext,
playbackOutput,
"playbackdumps/subtitles/sideloaded-parse-error.mp4.dump");
}
}
Loading

0 comments on commit 49dec5d

Please sign in to comment.