Update WebvttParser to implement new partial-output method

This requires adapting the 'to `CuesWithTiming` list' logic to work with
the new partial-output API, and that needs a private method so it's no
longer a good fit for a default method on `Subtitle` - hence moving it
to a new utility class.

Also update the implementation to never return `UNSET` duration (this is
an equivalent change to the `SsaParser` change in 9631923440).

PiperOrigin-RevId: 566598094
This commit is contained in:
ibaker 2023-09-19 05:12:47 -07:00 committed by Copybara-Service
parent 24e700c216
commit 92a3f3a8cd
6 changed files with 310 additions and 84 deletions

View File

@ -0,0 +1,74 @@
/*
* Copyright 2023 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
*
* https://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.extractor.text;
import static java.lang.Math.max;
import androidx.media3.common.C;
import androidx.media3.common.text.Cue;
import androidx.media3.common.util.Consumer;
import androidx.media3.common.util.UnstableApi;
import java.util.List;
/** Utility methods for working with legacy {@link Subtitle} objects. */
@UnstableApi
public class LegacySubtitleUtil {
private LegacySubtitleUtil() {}
/**
* Converts a {@link Subtitle} to a list of {@link CuesWithTiming} representing it, emitted to
* {@code output}.
*
* <p>This may only be called with {@link Subtitle} instances where the first event is non-empty
* and the last event is an empty cue list.
*/
public static void toCuesWithTiming(
Subtitle subtitle,
SubtitleParser.OutputOptions outputOptions,
Consumer<CuesWithTiming> output) {
int startIndex =
outputOptions.startTimeUs != C.TIME_UNSET
? max(subtitle.getNextEventTimeIndex(outputOptions.startTimeUs) - 1, 0)
: 0;
for (int i = startIndex; i < subtitle.getEventTimeCount(); i++) {
outputSubtitleEvent(subtitle, i, output);
}
if (outputOptions.outputAllCues) {
for (int i = 0; i < startIndex; i++) {
outputSubtitleEvent(subtitle, i, output);
}
}
}
private static void outputSubtitleEvent(
Subtitle subtitle, int eventIndex, Consumer<CuesWithTiming> output) {
long startTimeUs = subtitle.getEventTime(eventIndex);
List<Cue> cuesForThisStartTime = subtitle.getCues(startTimeUs);
if (cuesForThisStartTime.isEmpty()) {
// An empty cue list has already been implicitly encoded in the duration of the previous
// sample.
return;
} else if (eventIndex == subtitle.getEventTimeCount() - 1) {
// The last cue list must be empty
throw new IllegalStateException();
}
// It's safe to inspect element i+1, because we already exited the loop above if
// i == getEventTimeCount() - 1.
long durationUs = subtitle.getEventTime(eventIndex + 1) - subtitle.getEventTime(eventIndex);
output.accept(new CuesWithTiming(cuesForThisStartTime, startTimeUs, durationUs));
}
}

View File

@ -18,7 +18,6 @@ package androidx.media3.extractor.text;
import androidx.media3.common.C;
import androidx.media3.common.text.Cue;
import androidx.media3.common.util.UnstableApi;
import com.google.common.collect.ImmutableList;
import java.util.List;
/** A subtitle consisting of timed {@link Cue}s. */
@ -57,24 +56,4 @@ public interface Subtitle {
* @return A list of cues that should be displayed, possibly empty.
*/
List<Cue> getCues(long timeUs);
/** Converts the current instance to a list of {@link CuesWithTiming} representing it. */
// TODO(b/181312195): Remove this when TtmlDecoder has been migrated to TtmlParser (and in-line it
// in DelegatingSubtitleDecoderTtmlParserTest).
default ImmutableList<CuesWithTiming> toCuesWithTimingList() {
ImmutableList.Builder<CuesWithTiming> allCues = ImmutableList.builder();
for (int i = 0; i < getEventTimeCount(); i++) {
long startTimeUs = getEventTime(i);
List<Cue> cuesForThisStartTime = getCues(startTimeUs);
if (cuesForThisStartTime.isEmpty() && i != 0) {
// An empty cue list has already been implicitly encoded in the duration of the previous
// sample (unless there was no previous sample).
continue;
}
long durationUs =
i < getEventTimeCount() - 1 ? getEventTime(i + 1) - getEventTime(i) : C.TIME_UNSET;
allCues.add(new CuesWithTiming(cuesForThisStartTime, startTimeUs, durationUs));
}
return allCues.build();
}
}

View File

@ -18,9 +18,11 @@ package androidx.media3.extractor.text.webvtt;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.Consumer;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.extractor.text.CuesWithTiming;
import androidx.media3.extractor.text.LegacySubtitleUtil;
import androidx.media3.extractor.text.SubtitleParser;
import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
@ -53,6 +55,18 @@ public final class WebvttParser implements SubtitleParser {
@Override
public ImmutableList<CuesWithTiming> parse(byte[] data, int offset, int length) {
ImmutableList.Builder<CuesWithTiming> result = ImmutableList.builder();
parse(data, offset, length, OutputOptions.allCues(), result::add);
return result.build();
}
@Override
public void parse(
byte[] data,
int offset,
int length,
OutputOptions outputOptions,
Consumer<CuesWithTiming> output) {
parsableWebvttData.reset(data, /* limit= */ offset + length);
parsableWebvttData.setPosition(offset);
List<WebvttCssStyle> definedStyles = new ArrayList<>();
@ -85,7 +99,7 @@ public final class WebvttParser implements SubtitleParser {
}
}
WebvttSubtitle subtitle = new WebvttSubtitle(cueInfos);
return subtitle.toCuesWithTimingList();
LegacySubtitleUtil.toCuesWithTiming(subtitle, outputOptions, output);
}
/**

View File

@ -25,13 +25,11 @@ import androidx.media3.common.text.TextAnnotation;
import androidx.media3.common.text.TextEmphasisSpan;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.ColorParser;
import androidx.media3.extractor.text.CuesWithTiming;
import androidx.media3.extractor.text.Subtitle;
import androidx.media3.extractor.text.SubtitleDecoderException;
import androidx.media3.test.utils.TestUtil;
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.List;
import org.junit.Test;
@ -877,39 +875,6 @@ public final class TtmlDecoderTest {
assertThat(eighthCue.shearDegrees).isWithin(0.01f).of(90f);
}
@Test
public void toCuesWithTimingConversion() throws IOException, SubtitleDecoderException {
TtmlSubtitle subtitle = getSubtitle(INLINE_ATTRIBUTES_TTML_FILE);
ImmutableList<CuesWithTiming> cuesWithTimingsList = subtitle.toCuesWithTimingList();
assertThat(subtitle.getEventTimeCount()).isEqualTo(4);
assertThat(subtitle.getCues(subtitle.getEventTime(1))).isEmpty();
assertThat(subtitle.getCues(subtitle.getEventTime(3))).isEmpty();
// cuesWithTimingsList has 2 fewer the events because it skips the empty cues
assertThat(cuesWithTimingsList).hasSize(2);
subtitle = getSubtitle(INHERIT_STYLE_TTML_FILE);
cuesWithTimingsList = subtitle.toCuesWithTimingList();
assertThat(subtitle.getEventTimeCount()).isEqualTo(2);
assertThat(subtitle.getCues(subtitle.getEventTime(1))).isEmpty();
// cuesWithTimingsList has 1 fewer events because it skips the empty cues
assertThat(cuesWithTimingsList).hasSize(1);
subtitle = getSubtitle(INHERIT_MULTIPLE_STYLES_TTML_FILE);
cuesWithTimingsList = subtitle.toCuesWithTimingList();
assertThat(subtitle.getEventTimeCount()).isEqualTo(12);
assertThat(subtitle.getCues(subtitle.getEventTime(1))).isEmpty();
assertThat(subtitle.getCues(subtitle.getEventTime(3))).isEmpty();
assertThat(subtitle.getCues(subtitle.getEventTime(5))).isEmpty();
assertThat(subtitle.getCues(subtitle.getEventTime(7))).isEmpty();
assertThat(subtitle.getCues(subtitle.getEventTime(9))).isEmpty();
assertThat(subtitle.getCues(subtitle.getEventTime(11))).isEmpty();
// cuesWithTimingsList has 6 fewer events because it skips the empty cues
assertThat(cuesWithTimingsList).hasSize(6);
}
private static Spanned getOnlyCueTextAtTimeUs(Subtitle subtitle, long timeUs) {
Cue cue = getOnlyCueAtTimeUs(subtitle, timeUs);
assertThat(cue.text).isInstanceOf(Spanned.class);

View File

@ -0,0 +1,221 @@
/*
* Copyright 2023 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
*
* https://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.extractor.text.webvtt;
import static com.google.common.truth.Truth.assertThat;
import androidx.media3.extractor.text.CuesWithTiming;
import androidx.media3.extractor.text.LegacySubtitleUtil;
import androidx.media3.extractor.text.Subtitle;
import androidx.media3.extractor.text.SubtitleParser;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import java.util.Arrays;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Tests for {@link LegacySubtitleUtil} using {@link WebvttSubtitle}.
*
* <p>This is in the webvtt package so we don't need to increase the visibility of {@link
* WebvttSubtitle}.
*/
@RunWith(AndroidJUnit4.class)
public class LegacySubtitleUtilWebvttTest {
private static final String FIRST_SUBTITLE_STRING = "This is the first subtitle.";
private static final String SECOND_SUBTITLE_STRING = "This is the second subtitle.";
private static final WebvttSubtitle simpleSubtitle =
new WebvttSubtitle(
Arrays.asList(
new WebvttCueInfo(
WebvttCueParser.newCueForText(FIRST_SUBTITLE_STRING),
/* startTimeUs= */ 1_000_000,
/* endTimeUs= */ 2_000_000),
new WebvttCueInfo(
WebvttCueParser.newCueForText(SECOND_SUBTITLE_STRING),
/* startTimeUs= */ 3_000_000,
/* endTimeUs= */ 4_000_000)));
private static final WebvttSubtitle overlappingSubtitle =
new WebvttSubtitle(
Arrays.asList(
new WebvttCueInfo(
WebvttCueParser.newCueForText(FIRST_SUBTITLE_STRING),
/* startTimeUs= */ 1_000_000,
/* endTimeUs= */ 3_000_000),
new WebvttCueInfo(
WebvttCueParser.newCueForText(SECOND_SUBTITLE_STRING),
/* startTimeUs= */ 2_000_000,
/* endTimeUs= */ 4_000_000)));
@Test
public void toCuesWithTiming_allCues_simpleSubtitle() {
ImmutableList<CuesWithTiming> cuesWithTimingsList =
toCuesWithTimingList(simpleSubtitle, SubtitleParser.OutputOptions.allCues());
assertThat(cuesWithTimingsList).hasSize(2);
assertThat(cuesWithTimingsList.get(0).startTimeUs).isEqualTo(1_000_000);
assertThat(cuesWithTimingsList.get(0).durationUs).isEqualTo(1_000_000);
assertThat(Lists.transform(cuesWithTimingsList.get(0).cues, c -> c.text))
.containsExactly(FIRST_SUBTITLE_STRING);
assertThat(cuesWithTimingsList.get(1).startTimeUs).isEqualTo(3_000_000);
assertThat(cuesWithTimingsList.get(1).durationUs).isEqualTo(1_000_000);
assertThat(Lists.transform(cuesWithTimingsList.get(1).cues, c -> c.text))
.containsExactly(SECOND_SUBTITLE_STRING);
}
@Test
public void toCuesWithTiming_allCues_overlappingSubtitle() {
ImmutableList<CuesWithTiming> cuesWithTimingsList =
toCuesWithTimingList(overlappingSubtitle, SubtitleParser.OutputOptions.allCues());
assertThat(cuesWithTimingsList).hasSize(3);
assertThat(cuesWithTimingsList.get(0).startTimeUs).isEqualTo(1_000_000);
assertThat(cuesWithTimingsList.get(0).durationUs).isEqualTo(1_000_000);
assertThat(Lists.transform(cuesWithTimingsList.get(0).cues, c -> c.text))
.containsExactly(FIRST_SUBTITLE_STRING);
assertThat(cuesWithTimingsList.get(1).startTimeUs).isEqualTo(2_000_000);
assertThat(cuesWithTimingsList.get(1).durationUs).isEqualTo(1_000_000);
assertThat(Lists.transform(cuesWithTimingsList.get(1).cues, c -> c.text))
.containsExactly(FIRST_SUBTITLE_STRING, SECOND_SUBTITLE_STRING)
.inOrder();
assertThat(cuesWithTimingsList.get(2).startTimeUs).isEqualTo(3_000_000);
assertThat(cuesWithTimingsList.get(2).durationUs).isEqualTo(1_000_000);
assertThat(Lists.transform(cuesWithTimingsList.get(2).cues, c -> c.text))
.containsExactly(SECOND_SUBTITLE_STRING);
}
@Test
public void toCuesWithTiming_onlyEmitCuesAfterStartTime_startBetweenCues_simpleSubtitle() {
ImmutableList<CuesWithTiming> cuesWithTimingsList =
toCuesWithTimingList(simpleSubtitle, SubtitleParser.OutputOptions.onlyCuesAfter(2_500_000));
assertThat(cuesWithTimingsList).hasSize(1);
assertThat(cuesWithTimingsList.get(0).startTimeUs).isEqualTo(3_000_000);
assertThat(cuesWithTimingsList.get(0).durationUs).isEqualTo(1_000_000);
assertThat(Lists.transform(cuesWithTimingsList.get(0).cues, c -> c.text))
.containsExactly(SECOND_SUBTITLE_STRING);
}
@Test
public void toCuesWithTiming_onlyEmitCuesAfterStartTime_startAtCueEnd_simpleSubtitle() {
ImmutableList<CuesWithTiming> cuesWithTimingsList =
toCuesWithTimingList(simpleSubtitle, SubtitleParser.OutputOptions.onlyCuesAfter(2_000_000));
assertThat(cuesWithTimingsList).hasSize(1);
assertThat(cuesWithTimingsList.get(0).startTimeUs).isEqualTo(3_000_000);
assertThat(cuesWithTimingsList.get(0).durationUs).isEqualTo(1_000_000);
assertThat(Lists.transform(cuesWithTimingsList.get(0).cues, c -> c.text))
.containsExactly(SECOND_SUBTITLE_STRING);
}
@Test
public void toCuesWithTiming_onlyEmitCuesAfterStartTime_startAtCueStart_simpleSubtitle() {
ImmutableList<CuesWithTiming> cuesWithTimingsList =
toCuesWithTimingList(simpleSubtitle, SubtitleParser.OutputOptions.onlyCuesAfter(3_000_000));
assertThat(cuesWithTimingsList).hasSize(1);
assertThat(cuesWithTimingsList.get(0).startTimeUs).isEqualTo(3_000_000);
assertThat(cuesWithTimingsList.get(0).durationUs).isEqualTo(1_000_000);
assertThat(Lists.transform(cuesWithTimingsList.get(0).cues, c -> c.text))
.containsExactly(SECOND_SUBTITLE_STRING);
}
@Test
public void toCuesWithTiming_onlyEmitCuesAfterStartTime_startInMiddleOfCue_simpleSubtitle() {
ImmutableList<CuesWithTiming> cuesWithTimingsList =
toCuesWithTimingList(simpleSubtitle, SubtitleParser.OutputOptions.onlyCuesAfter(1_500_000));
assertThat(cuesWithTimingsList).hasSize(2);
assertThat(cuesWithTimingsList.get(0).startTimeUs).isEqualTo(1_000_000);
assertThat(cuesWithTimingsList.get(0).durationUs).isEqualTo(1_000_000);
assertThat(Lists.transform(cuesWithTimingsList.get(0).cues, c -> c.text))
.containsExactly(FIRST_SUBTITLE_STRING);
assertThat(cuesWithTimingsList.get(1).startTimeUs).isEqualTo(3_000_000);
assertThat(cuesWithTimingsList.get(1).durationUs).isEqualTo(1_000_000);
assertThat(Lists.transform(cuesWithTimingsList.get(1).cues, c -> c.text))
.containsExactly(SECOND_SUBTITLE_STRING);
}
@Test
public void toCuesWithTiming_onlyEmitCuesAfterStartTime_overlappingSubtitle() {
ImmutableList<CuesWithTiming> cuesWithTimingsList =
toCuesWithTimingList(
overlappingSubtitle, SubtitleParser.OutputOptions.onlyCuesAfter(2_500_000));
assertThat(cuesWithTimingsList).hasSize(2);
assertThat(cuesWithTimingsList.get(0).startTimeUs).isEqualTo(2_000_000);
assertThat(cuesWithTimingsList.get(0).durationUs).isEqualTo(1_000_000);
assertThat(Lists.transform(cuesWithTimingsList.get(0).cues, c -> c.text))
.containsExactly(FIRST_SUBTITLE_STRING, SECOND_SUBTITLE_STRING)
.inOrder();
assertThat(cuesWithTimingsList.get(1).startTimeUs).isEqualTo(3_000_000);
assertThat(cuesWithTimingsList.get(1).durationUs).isEqualTo(1_000_000);
assertThat(Lists.transform(cuesWithTimingsList.get(1).cues, c -> c.text))
.containsExactly(SECOND_SUBTITLE_STRING);
}
@Test
public void toCuesWithTiming_emitCuesAfterStartTimeThenThoseBefore_simpleSubtitle() {
ImmutableList<CuesWithTiming> cuesWithTimingsList =
toCuesWithTimingList(
simpleSubtitle,
SubtitleParser.OutputOptions.cuesAfterThenRemainingCuesBefore(2_500_000));
assertThat(cuesWithTimingsList).hasSize(2);
assertThat(cuesWithTimingsList.get(0).startTimeUs).isEqualTo(3_000_000);
assertThat(cuesWithTimingsList.get(0).durationUs).isEqualTo(1_000_000);
assertThat(Lists.transform(cuesWithTimingsList.get(0).cues, c -> c.text))
.containsExactly(SECOND_SUBTITLE_STRING);
assertThat(cuesWithTimingsList.get(1).startTimeUs).isEqualTo(1_000_000);
assertThat(cuesWithTimingsList.get(1).durationUs).isEqualTo(1_000_000);
assertThat(Lists.transform(cuesWithTimingsList.get(1).cues, c -> c.text))
.containsExactly(FIRST_SUBTITLE_STRING);
}
@Test
public void toCuesWithTiming_emitCuesAfterStartTimeThenThoseBefore_overlappingSubtitle() {
ImmutableList<CuesWithTiming> cuesWithTimingsList =
toCuesWithTimingList(
overlappingSubtitle,
SubtitleParser.OutputOptions.cuesAfterThenRemainingCuesBefore(2_500_000));
assertThat(cuesWithTimingsList).hasSize(3);
assertThat(cuesWithTimingsList.get(0).startTimeUs).isEqualTo(2_000_000);
assertThat(cuesWithTimingsList.get(0).durationUs).isEqualTo(1_000_000);
assertThat(Lists.transform(cuesWithTimingsList.get(0).cues, c -> c.text))
.containsExactly(FIRST_SUBTITLE_STRING, SECOND_SUBTITLE_STRING)
.inOrder();
assertThat(cuesWithTimingsList.get(1).startTimeUs).isEqualTo(3_000_000);
assertThat(cuesWithTimingsList.get(1).durationUs).isEqualTo(1_000_000);
assertThat(Lists.transform(cuesWithTimingsList.get(1).cues, c -> c.text))
.containsExactly(SECOND_SUBTITLE_STRING);
assertThat(cuesWithTimingsList.get(2).startTimeUs).isEqualTo(1_000_000);
assertThat(cuesWithTimingsList.get(2).durationUs).isEqualTo(1_000_000);
assertThat(Lists.transform(cuesWithTimingsList.get(2).cues, c -> c.text))
.containsExactly(FIRST_SUBTITLE_STRING);
}
private static ImmutableList<CuesWithTiming> toCuesWithTimingList(
Subtitle subtitle, SubtitleParser.OutputOptions outputOptions) {
ImmutableList.Builder<CuesWithTiming> result = ImmutableList.builder();
LegacySubtitleUtil.toCuesWithTiming(subtitle, outputOptions, result::add);
return result.build();
}
}

View File

@ -20,9 +20,7 @@ import static com.google.common.truth.Truth.assertThat;
import static java.lang.Long.MAX_VALUE;
import androidx.media3.common.text.Cue;
import androidx.media3.extractor.text.CuesWithTiming;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@ -299,31 +297,6 @@ public class WebvttSubtitleTest {
assertThat(nestedSubtitle.getCues(Long.MAX_VALUE)).isEmpty();
}
@Test
public void toCuesWithTimingConversion() {
ImmutableList<CuesWithTiming> cuesWithTimingsList = simpleSubtitle.toCuesWithTimingList();
assertThat(simpleSubtitle.getEventTimeCount()).isEqualTo(4);
assertThat(simpleSubtitle.getCues(simpleSubtitle.getEventTime(1))).isEmpty();
assertThat(simpleSubtitle.getCues(simpleSubtitle.getEventTime(3))).isEmpty();
// cuesWithTimingsList has half the events because it skips the empty cues
assertThat(cuesWithTimingsList).hasSize(2);
cuesWithTimingsList = overlappingSubtitle.toCuesWithTimingList();
assertThat(overlappingSubtitle.getEventTimeCount()).isEqualTo(4);
assertThat(overlappingSubtitle.getCues(overlappingSubtitle.getEventTime(3))).isEmpty();
// cuesWithTimingsList has one fewer events because it skips the empty cues
assertThat(cuesWithTimingsList).hasSize(3);
cuesWithTimingsList = nestedSubtitle.toCuesWithTimingList();
assertThat(nestedSubtitle.getEventTimeCount()).isEqualTo(4);
assertThat(nestedSubtitle.getCues(nestedSubtitle.getEventTime(3))).isEmpty();
// cuesWithTimingsList has one fewer events because it skips the empty cues
assertThat(cuesWithTimingsList).hasSize(3);
}
private static List<String> getCueTexts(List<Cue> cues) {
List<String> cueTexts = new ArrayList<>();
for (int i = 0; i < cues.size(); i++) {