Migrate CEA-608 logic to a SubtitleParser implementation

PiperOrigin-RevId: 578505919
This commit is contained in:
ibaker 2023-11-01 07:04:49 -07:00 committed by Copybara-Service
parent d42c23706b
commit 27caeb8038
6 changed files with 1559 additions and 1080 deletions

View file

@ -23,6 +23,7 @@ import androidx.media3.extractor.text.DefaultSubtitleParserFactory;
import androidx.media3.extractor.text.SubtitleDecoder;
import androidx.media3.extractor.text.SubtitleParser;
import androidx.media3.extractor.text.cea.Cea608Decoder;
import androidx.media3.extractor.text.cea.Cea608Parser;
import androidx.media3.extractor.text.cea.Cea708Decoder;
import java.util.Objects;
@ -85,9 +86,10 @@ public interface SubtitleDecoderFactory {
case MimeTypes.APPLICATION_CEA608:
case MimeTypes.APPLICATION_MP4CEA608:
return new Cea608Decoder(
mimeType,
format.accessibilityChannel,
Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS);
new Cea608Parser(
mimeType,
format.accessibilityChannel,
Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS));
case MimeTypes.APPLICATION_CEA708:
return new Cea708Decoder(format.accessibilityChannel, format.initializationData);
default:

View file

@ -20,6 +20,7 @@ import androidx.media3.common.Format;
import androidx.media3.common.Format.CueReplacementBehavior;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.extractor.text.cea.Cea608Parser;
import androidx.media3.extractor.text.dvb.DvbParser;
import androidx.media3.extractor.text.pgs.PgsParser;
import androidx.media3.extractor.text.ssa.SsaParser;
@ -44,6 +45,7 @@ import java.util.Objects;
* <li>PGS ({@link PgsParser})
* <li>DVB ({@link DvbParser})
* <li>TTML ({@link TtmlParser})
* <li>CEA-608 ({@link Cea608Parser})
* </ul>
*/
@UnstableApi
@ -59,7 +61,9 @@ public final class DefaultSubtitleParserFactory implements SubtitleParser.Factor
|| Objects.equals(mimeType, MimeTypes.APPLICATION_TX3G)
|| Objects.equals(mimeType, MimeTypes.APPLICATION_PGS)
|| Objects.equals(mimeType, MimeTypes.APPLICATION_DVBSUBS)
|| Objects.equals(mimeType, MimeTypes.APPLICATION_TTML);
|| Objects.equals(mimeType, MimeTypes.APPLICATION_TTML)
|| Objects.equals(mimeType, MimeTypes.APPLICATION_MP4CEA608)
|| Objects.equals(mimeType, MimeTypes.APPLICATION_CEA608);
}
@Override
@ -83,6 +87,9 @@ public final class DefaultSubtitleParserFactory implements SubtitleParser.Factor
return DvbParser.CUE_REPLACEMENT_BEHAVIOR;
case MimeTypes.APPLICATION_TTML:
return TtmlParser.CUE_REPLACEMENT_BEHAVIOR;
case MimeTypes.APPLICATION_MP4CEA608:
case MimeTypes.APPLICATION_CEA608:
return Cea608Parser.CUE_REPLACEMENT_BEHAVIOR;
default:
break;
}
@ -111,6 +118,15 @@ public final class DefaultSubtitleParserFactory implements SubtitleParser.Factor
return new DvbParser(format.initializationData);
case MimeTypes.APPLICATION_TTML:
return new TtmlParser();
case MimeTypes.APPLICATION_MP4CEA608:
case MimeTypes.APPLICATION_CEA608:
// Deliberately pass a timeout longer than Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS
// because Cea608Parser erases 'stuck' cues starting from their start time, rather than
// the last piece of CEA data received.
return new Cea608Parser(
mimeType,
format.accessibilityChannel,
/* validDataChannelTimeoutMs= */ 2 * Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS);
default:
break;
}

View file

@ -45,9 +45,10 @@ public class Cea608DecoderTest {
public void paintOnEmitsSubtitlesImmediately() throws Exception {
Cea608Decoder decoder =
new Cea608Decoder(
MimeTypes.APPLICATION_CEA608,
/* accessibilityChannel= */ 1,
Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS);
new Cea608Parser(
MimeTypes.APPLICATION_CEA608,
/* accessibilityChannel= */ 1,
Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS));
byte[] sample1 =
Bytes.concat(
// 'paint on' control character
@ -82,9 +83,10 @@ public class Cea608DecoderTest {
public void rollUpEmitsSubtitlesImmediately() throws Exception {
Cea608Decoder decoder =
new Cea608Decoder(
MimeTypes.APPLICATION_CEA608,
/* accessibilityChannel= */ 1, // field 1, channel 1
Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS);
new Cea608Parser(
MimeTypes.APPLICATION_CEA608,
/* accessibilityChannel= */ 1, // field 1, channel 1
Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS));
byte[] sample1 =
Bytes.concat(
// 'roll up 2 rows' control character
@ -133,9 +135,10 @@ public class Cea608DecoderTest {
public void onlySelectedFieldIsUsed() throws Exception {
Cea608Decoder decoder =
new Cea608Decoder(
MimeTypes.APPLICATION_CEA608,
/* accessibilityChannel= */ 1, // field 1, channel 1
Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS);
new Cea608Parser(
MimeTypes.APPLICATION_CEA608,
/* accessibilityChannel= */ 1, // field 1, channel 1
Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS));
// field 1 (0xFC header): 'test subtitle'
// field 2 (0xFD header): 'wrong field!'
byte[] sample1 =
@ -171,9 +174,10 @@ public class Cea608DecoderTest {
public void onlySelectedChannelIsUsed() throws Exception {
Cea608Decoder decoder =
new Cea608Decoder(
MimeTypes.APPLICATION_CEA608,
/* accessibilityChannel= */ 2, // field 1, channel 2
Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS);
new Cea608Parser(
MimeTypes.APPLICATION_CEA608,
/* accessibilityChannel= */ 2, // field 1, channel 2
Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS));
// field 1 (0xFC header), channel 1: 'wrong channel'
// field 1 (0xFC header), channel 2: 'test subtitle'
// field 2 (0xFD header), channel 1: 'wrong field!'
@ -259,9 +263,10 @@ public class Cea608DecoderTest {
public void serviceSwitchOnField1Handled() throws Exception {
Cea608Decoder decoder =
new Cea608Decoder(
MimeTypes.APPLICATION_CEA608,
/* accessibilityChannel= */ 1, // field 1, channel 1
Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS);
new Cea608Parser(
MimeTypes.APPLICATION_CEA608,
/* accessibilityChannel= */ 1, // field 1, channel 1
Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS));
// field 1 (0xFC header): 'test' then service switch
// field 2 (0xFD header): 'wrong!'
byte[] sample1 =
@ -289,9 +294,10 @@ public class Cea608DecoderTest {
public void serviceSwitchOnField2Handled() throws Exception {
Cea608Decoder decoder =
new Cea608Decoder(
MimeTypes.APPLICATION_CEA608,
/* accessibilityChannel= */ 3, // field 2, channel 1
Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS);
new Cea608Parser(
MimeTypes.APPLICATION_CEA608,
/* accessibilityChannel= */ 3, // field 2, channel 1
Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS));
// field 1 (0xFC header): 'wrong!'
// field 2 (0xFD header): 'test' then service switch
byte[] sample1 =

View file

@ -0,0 +1,342 @@
/*
* Copyright 2022 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.cea;
import static androidx.media3.common.util.Assertions.checkArgument;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static com.google.common.truth.Truth.assertThat;
import androidx.annotation.Nullable;
import androidx.media3.common.MimeTypes;
import androidx.media3.extractor.text.CuesWithTiming;
import androidx.media3.extractor.text.SubtitleDecoderException;
import androidx.media3.extractor.text.SubtitleParser.OutputOptions;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.Iterables;
import com.google.common.primitives.Bytes;
import com.google.common.primitives.UnsignedBytes;
import java.util.ArrayList;
import java.util.List;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Tests for {@link Cea608Parser}. */
@RunWith(AndroidJUnit4.class)
public class Cea608ParserTest {
@Test
public void paintOnEmitsSubtitlesImmediately() throws Exception {
Cea608Parser cea608Parser =
new Cea608Parser(
MimeTypes.APPLICATION_CEA608,
/* accessibilityChannel= */ 1,
Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS);
byte[] sample1 =
Bytes.concat(
// 'paint on' control character
createPacket(0xFC, 0x14, 0x29),
createPacket(0xFC, 't', 'e'),
createPacket(0xFC, 's', 't'),
createPacket(0xFC, ' ', 's'),
createPacket(0xFC, 'u', 'b'),
createPacket(0xFC, 't', 'i'),
createPacket(0xFC, 't', 'l'),
createPacket(0xFC, 'e', ','),
createPacket(0xFC, ' ', 's'),
createPacket(0xFC, 'p', 'a'));
byte[] sample2 =
Bytes.concat(
createPacket(0xFC, 'n', 's'),
createPacket(0xFC, ' ', '2'),
createPacket(0xFC, ' ', 's'),
createPacket(0xFC, 'a', 'm'),
createPacket(0xFC, 'p', 'l'),
createPacket(0xFC, 'e', 's'));
CuesWithTiming firstCues = checkNotNull(parseSample(cea608Parser, sample1));
CuesWithTiming secondCues = checkNotNull(parseSample(cea608Parser, sample2));
assertThat(Iterables.getOnlyElement(firstCues.cues).text.toString())
.isEqualTo("test subtitle, spa");
assertThat(Iterables.getOnlyElement(secondCues.cues).text.toString())
.isEqualTo("test subtitle, spans 2 samples");
}
@Test
public void rollUpEmitsSubtitlesImmediately() throws Exception {
Cea608Parser cea608Parser =
new Cea608Parser(
MimeTypes.APPLICATION_CEA608,
/* accessibilityChannel= */ 1, // field 1, channel 1
Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS);
byte[] sample1 =
Bytes.concat(
// 'roll up 2 rows' control character
createPacket(0xFC, 0x14, 0x25),
createPacket(0xFC, 't', 'e'),
createPacket(0xFC, 's', 't'),
createPacket(0xFC, ' ', 's'),
createPacket(0xFC, 'u', 'b'),
createPacket(0xFC, 't', 'i'),
createPacket(0xFC, 't', 'l'),
createPacket(0xFC, 'e', ','),
createPacket(0xFC, ' ', 's'),
createPacket(0xFC, 'p', 'a'));
byte[] sample2 =
Bytes.concat(
createPacket(0xFC, 'n', 's'),
createPacket(0xFC, ' ', '3'),
createPacket(0xFC, ' ', 's'),
createPacket(0xFC, 'a', 'm'),
createPacket(0xFC, 'p', 'l'),
createPacket(0xFC, 'e', 's'),
// Carriage return control character
createPacket(0xFC, 0x14, 0x2D),
createPacket(0xFC, 'w', 'i'),
createPacket(0xFC, 't', 'h'),
createPacket(0xFC, ' ', 'n'));
byte[] sample3 =
Bytes.concat(
createPacket(0xFC, 'e', 'w'),
createPacket(0xFC, 'l', 'i'),
createPacket(0xFC, 'n', 'e'),
createPacket(0xFC, 's', 0x0));
CuesWithTiming firstCues = checkNotNull(parseSample(cea608Parser, sample1));
CuesWithTiming secondCues = checkNotNull(parseSample(cea608Parser, sample2));
CuesWithTiming thirdCues = checkNotNull(parseSample(cea608Parser, sample3));
assertThat(Iterables.getOnlyElement(firstCues.cues).text.toString())
.isEqualTo("test subtitle, spa");
assertThat(Iterables.getOnlyElement(secondCues.cues).text.toString())
.isEqualTo("test subtitle, spans 3 samples\nwith n");
assertThat(Iterables.getOnlyElement(thirdCues.cues).text.toString())
.isEqualTo("test subtitle, spans 3 samples\nwith newlines");
}
@Test
public void onlySelectedFieldIsUsed() throws Exception {
Cea608Parser cea608Parser =
new Cea608Parser(
MimeTypes.APPLICATION_CEA608,
/* accessibilityChannel= */ 1, // field 1, channel 1
Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS);
// field 1 (0xFC header): 'test subtitle'
// field 2 (0xFD header): 'wrong field!'
byte[] sample1 =
Bytes.concat(
// 'paint on' control character
createPacket(0xFC, 0x14, 0x29),
createPacket(0xFD, 0x15, 0x29),
createPacket(0xFC, 't', 'e'),
createPacket(0xFD, 'w', 'r'),
createPacket(0xFC, 's', 't'),
createPacket(0xFD, 'o', 'n'),
createPacket(0xFC, ' ', 's'),
createPacket(0xFD, 'g', ' '),
createPacket(0xFC, 'u', 'b'),
createPacket(0xFD, 'f', 'i'));
byte[] sample2 =
Bytes.concat(
createPacket(0xFC, 't', 'i'),
createPacket(0xFD, 'e', 'l'),
createPacket(0xFC, 't', 'l'),
createPacket(0xFD, 'd', '!'),
createPacket(0xFC, 'e', 0x0),
createPacket(0xFD, 0x0, 0x0));
CuesWithTiming firstCues = checkNotNull(parseSample(cea608Parser, sample1));
CuesWithTiming secondCues = checkNotNull(parseSample(cea608Parser, sample2));
assertThat(Iterables.getOnlyElement(firstCues.cues).text.toString()).isEqualTo("test sub");
assertThat(Iterables.getOnlyElement(secondCues.cues).text.toString())
.isEqualTo("test subtitle");
}
@Test
public void onlySelectedChannelIsUsed() throws Exception {
Cea608Parser cea608Parser =
new Cea608Parser(
MimeTypes.APPLICATION_CEA608,
/* accessibilityChannel= */ 2, // field 1, channel 2
Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS);
// field 1 (0xFC header), channel 1: 'wrong channel'
// field 1 (0xFC header), channel 2: 'test subtitle'
// field 2 (0xFD header), channel 1: 'wrong field!'
byte[] sample1 =
Bytes.concat(
// 'paint on' control character
createPacket(0xFC, 0x14, 0x29),
createPacket(0xFD, 0x15, 0x29),
createPacket(0xFC, 'w', 'r'),
createPacket(0xFD, 'w', 'r'),
createPacket(0xFC, 'o', 'n'),
createPacket(0xFD, 'o', 'n'),
// Switch to channel 2 & 'paint on' control character
createPacket(0xFC, 0x14 | 0x08, 0x29),
createPacket(0xFD, 'g', ' '),
createPacket(0xFC, 't', 'e'),
createPacket(0xFD, 'f', 'i'));
byte[] sample2 =
Bytes.concat(
createPacket(0xFC, 's', 't'),
createPacket(0xFD, 'e', 'l'),
// Switch to channel 1
createPacket(0xFC, 0x14, 0x0),
createPacket(0xFD, 'd', '!'),
createPacket(0xFC, 'g', ' '),
createPacket(0xFD, 0x0, 0x0),
createPacket(0xFC, 'c', 'h'),
createPacket(0xFD, 0x0, 0x0),
// Switch to channel 2
createPacket(0xFC, 0x14 | 0x08, 0x0),
createPacket(0xFD, 0x0, 0x0));
byte[] sample3 =
Bytes.concat(
createPacket(0xFC, ' ', 's'),
createPacket(0xFD, 0x0, 0x0),
createPacket(0xFC, 'u', 'b'),
createPacket(0xFD, 0x0, 0x0),
// Switch to channel 1
createPacket(0xFC, 0x14, 0x0),
createPacket(0xFD, 0x0, 0x0),
createPacket(0xFC, 'a', 'n'),
createPacket(0xFD, 0x0, 0x0),
createPacket(0xFC, 'n', 'e'),
createPacket(0xFD, 0x0, 0x0));
byte[] sample4 =
Bytes.concat(
// Switch to channel 2
createPacket(0xFC, 0x14 | 0x08, 0x0),
createPacket(0xFD, 0x0, 0x0),
createPacket(0xFC, 't', 'i'),
createPacket(0xFD, 0x0, 0x0),
createPacket(0xFC, 't', 'l'),
createPacket(0xFD, 0x0, 0x0),
// Switch to channel 1
createPacket(0xFC, 0x14, 0x0),
createPacket(0xFD, 0x0, 0x0),
createPacket(0xFC, 'l', 0x0),
createPacket(0xFD, 0x0, 0x0));
byte[] sample5 =
Bytes.concat(
createPacket(0xFC, 0x0, 0x0),
createPacket(0xFD, 0x0, 0x0),
// Switch to channel 2
createPacket(0xFC, 0x14 | 0x08, 0x0),
createPacket(0xFD, 0x0, 0x0),
createPacket(0xFC, 'e', 0x0),
createPacket(0xFD, 0x0, 0x0));
CuesWithTiming firstCues = checkNotNull(parseSample(cea608Parser, sample1));
CuesWithTiming secondCues = checkNotNull(parseSample(cea608Parser, sample2));
CuesWithTiming thirdCues = checkNotNull(parseSample(cea608Parser, sample3));
CuesWithTiming fourthCues = checkNotNull(parseSample(cea608Parser, sample4));
CuesWithTiming fifthCues = checkNotNull(parseSample(cea608Parser, sample5));
assertThat(Iterables.getOnlyElement(firstCues.cues).text.toString()).isEqualTo("te");
assertThat(Iterables.getOnlyElement(secondCues.cues).text.toString()).isEqualTo("test");
assertThat(Iterables.getOnlyElement(thirdCues.cues).text.toString()).isEqualTo("test sub");
assertThat(Iterables.getOnlyElement(fourthCues.cues).text.toString()).isEqualTo("test subtitl");
assertThat(Iterables.getOnlyElement(fifthCues.cues).text.toString()).isEqualTo("test subtitle");
}
@Test
public void serviceSwitchOnField1Handled() throws Exception {
Cea608Parser cea608Parser =
new Cea608Parser(
MimeTypes.APPLICATION_CEA608,
/* accessibilityChannel= */ 1, // field 1, channel 1
Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS);
// field 1 (0xFC header): 'test' then service switch
// field 2 (0xFD header): 'wrong!'
byte[] sample1 =
Bytes.concat(
// 'paint on' control character
createPacket(0xFC, 0x14, 0x29),
createPacket(0xFD, 0x15, 0x29),
createPacket(0xFC, 't', 'e'),
createPacket(0xFD, 'w', 'r'),
createPacket(0xFC, 's', 't'),
createPacket(0xFD, 'o', 'n'),
// Enter TEXT service
createPacket(0xFC, 0x14, 0x2A),
createPacket(0xFD, 'g', '!'),
createPacket(0xFC, 'X', 'X'),
createPacket(0xFD, 0x0, 0x0));
CuesWithTiming firstCues = checkNotNull(parseSample(cea608Parser, sample1));
assertThat(Iterables.getOnlyElement(firstCues.cues).text.toString()).isEqualTo("test");
}
// https://github.com/google/ExoPlayer/issues/10666
@Test
public void serviceSwitchOnField2Handled() throws Exception {
Cea608Parser cea608Parser =
new Cea608Parser(
MimeTypes.APPLICATION_CEA608,
/* accessibilityChannel= */ 3, // field 2, channel 1
Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS);
// field 1 (0xFC header): 'wrong!'
// field 2 (0xFD header): 'test' then service switch
byte[] sample1 =
Bytes.concat(
// 'paint on' control character
createPacket(0xFC, 0x14, 0x29),
createPacket(0xFD, 0x15, 0x29),
createPacket(0xFC, 'w', 'r'),
createPacket(0xFD, 't', 'e'),
createPacket(0xFC, 'o', 'n'),
createPacket(0xFD, 's', 't'),
createPacket(0xFC, 'g', '!'),
// Enter TEXT service
createPacket(0xFD, 0x15, 0x2A),
createPacket(0xFC, 0x0, 0x0),
createPacket(0xFD, 'X', 'X'));
CuesWithTiming firstCues = checkNotNull(parseSample(cea608Parser, sample1));
assertThat(Iterables.getOnlyElement(firstCues.cues).text.toString()).isEqualTo("test");
}
private static byte[] createPacket(int header, int cc1, int cc2) {
return new byte[] {
UnsignedBytes.checkedCast(header),
ensureUnsignedByteOddParity(cc1),
ensureUnsignedByteOddParity(cc2)
};
}
private static byte ensureUnsignedByteOddParity(int input) {
checkArgument(input >= 0);
checkArgument(input < 128);
return UnsignedBytes.checkedCast(Integer.bitCount(input) % 2 == 0 ? input | 0x80 : input);
}
/**
* Passes {@code sample} to {@link Cea608Parser#parse} and returns either the emitted {@link
* CuesWithTiming} or null if none was emitted.
*/
@Nullable
private static CuesWithTiming parseSample(Cea608Parser parser, byte[] sample)
throws SubtitleDecoderException {
List<CuesWithTiming> result = new ArrayList<>();
parser.parse(sample, OutputOptions.allCues(), result::add);
return result.isEmpty() ? null : Iterables.getOnlyElement(result);
}
}