From fa82004fa3423021a25149bce5f2a50e884ab804 Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Fri, 14 Dec 2018 15:42:57 +0000 Subject: [PATCH] Merge pull request #5066 from szaboa:feature/1583_support_png_ttml PiperOrigin-RevId: 225531695 --- .../exoplayer2/text/ttml/TtmlDecoder.java | 140 ++++++++++++-- .../exoplayer2/text/ttml/TtmlNode.java | 117 +++++++++--- .../exoplayer2/text/ttml/TtmlSubtitle.java | 11 +- .../assets/ttml/bitmap_percentage_region.xml | 26 +++ .../test/assets/ttml/bitmap_pixel_region.xml | 23 +++ .../assets/ttml/bitmap_unsupported_region.xml | 23 +++ .../exoplayer2/text/ttml/TtmlDecoderTest.java | 178 +++++++++++++----- 7 files changed, 429 insertions(+), 89 deletions(-) create mode 100644 library/core/src/test/assets/ttml/bitmap_percentage_region.xml create mode 100644 library/core/src/test/assets/ttml/bitmap_pixel_region.xml create mode 100644 library/core/src/test/assets/ttml/bitmap_unsupported_region.xml diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java index 2e868077a5..b39f467968 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java @@ -68,6 +68,7 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { private static final String ATTR_END = "end"; private static final String ATTR_STYLE = "style"; private static final String ATTR_REGION = "region"; + private static final String ATTR_IMAGE = "backgroundImage"; private static final Pattern CLOCK_TIME = Pattern.compile("^([0-9][0-9]+):([0-9][0-9]):([0-9][0-9])" @@ -77,6 +78,8 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { private static final Pattern FONT_SIZE = Pattern.compile("^(([0-9]*.)?[0-9]+)(px|em|%)$"); private static final Pattern PERCENTAGE_COORDINATES = Pattern.compile("^(\\d+\\.?\\d*?)% (\\d+\\.?\\d*?)%$"); + private static final Pattern PIXEL_COORDINATES = + Pattern.compile("^(\\d+\\.?\\d*?)px (\\d+\\.?\\d*?)px$"); private static final Pattern CELL_RESOLUTION = Pattern.compile("^(\\d+) (\\d+)$"); private static final int DEFAULT_FRAME_RATE = 30; @@ -105,6 +108,7 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { XmlPullParser xmlParser = xmlParserFactory.newPullParser(); Map globalStyles = new HashMap<>(); Map regionMap = new HashMap<>(); + Map imageMap = new HashMap<>(); regionMap.put(TtmlNode.ANONYMOUS_REGION_ID, new TtmlRegion(null)); ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes, 0, length); xmlParser.setInput(inputStream, null); @@ -114,6 +118,7 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { int eventType = xmlParser.getEventType(); FrameAndTickRate frameAndTickRate = DEFAULT_FRAME_AND_TICK_RATE; CellResolution cellResolution = DEFAULT_CELL_RESOLUTION; + TtsExtent ttsExtent = null; while (eventType != XmlPullParser.END_DOCUMENT) { TtmlNode parent = nodeStack.peek(); if (unsupportedNodeDepth == 0) { @@ -122,12 +127,13 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { if (TtmlNode.TAG_TT.equals(name)) { frameAndTickRate = parseFrameAndTickRates(xmlParser); cellResolution = parseCellResolution(xmlParser, DEFAULT_CELL_RESOLUTION); + ttsExtent = parseTtsExtent(xmlParser); } if (!isSupportedTag(name)) { Log.i(TAG, "Ignoring unsupported tag: " + xmlParser.getName()); unsupportedNodeDepth++; } else if (TtmlNode.TAG_HEAD.equals(name)) { - parseHeader(xmlParser, globalStyles, regionMap, cellResolution); + parseHeader(xmlParser, globalStyles, cellResolution, ttsExtent, regionMap, imageMap); } else { try { TtmlNode node = parseNode(xmlParser, parent, regionMap, frameAndTickRate); @@ -145,7 +151,7 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { parent.addChild(TtmlNode.buildTextNode(xmlParser.getText())); } else if (eventType == XmlPullParser.END_TAG) { if (xmlParser.getName().equals(TtmlNode.TAG_TT)) { - ttmlSubtitle = new TtmlSubtitle(nodeStack.peek(), globalStyles, regionMap); + ttmlSubtitle = new TtmlSubtitle(nodeStack.peek(), globalStyles, regionMap, imageMap); } nodeStack.pop(); } @@ -226,11 +232,34 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { } } + private TtsExtent parseTtsExtent(XmlPullParser xmlParser) { + String ttsExtent = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_EXTENT); + if (ttsExtent == null) { + return null; + } + + Matcher extentMatcher = PIXEL_COORDINATES.matcher(ttsExtent); + if (!extentMatcher.matches()) { + Log.w(TAG, "Ignoring non-pixel tts extent: " + ttsExtent); + return null; + } + try { + int width = Integer.parseInt(extentMatcher.group(1)); + int height = Integer.parseInt(extentMatcher.group(2)); + return new TtsExtent(width, height); + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring malformed tts extent: " + ttsExtent); + return null; + } + } + private Map parseHeader( XmlPullParser xmlParser, Map globalStyles, + CellResolution cellResolution, + TtsExtent ttsExtent, Map globalRegions, - CellResolution cellResolution) + Map imageMap) throws IOException, XmlPullParserException { do { xmlParser.next(); @@ -246,23 +275,41 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { globalStyles.put(style.getId(), style); } } else if (XmlPullParserUtil.isStartTag(xmlParser, TtmlNode.TAG_REGION)) { - TtmlRegion ttmlRegion = parseRegionAttributes(xmlParser, cellResolution); + TtmlRegion ttmlRegion = parseRegionAttributes(xmlParser, cellResolution, ttsExtent); if (ttmlRegion != null) { globalRegions.put(ttmlRegion.id, ttmlRegion); } + } else if (XmlPullParserUtil.isStartTag(xmlParser, TtmlNode.TAG_METADATA)) { + parseMetadata(xmlParser, imageMap); } } while (!XmlPullParserUtil.isEndTag(xmlParser, TtmlNode.TAG_HEAD)); return globalStyles; } + private void parseMetadata(XmlPullParser xmlParser, Map imageMap) + throws IOException, XmlPullParserException { + do { + xmlParser.next(); + if (XmlPullParserUtil.isStartTag(xmlParser, TtmlNode.TAG_IMAGE)) { + String id = XmlPullParserUtil.getAttributeValue(xmlParser, "id"); + if (id != null) { + String encodedBitmapData = xmlParser.nextText(); + imageMap.put(id, encodedBitmapData); + } + } + } while (!XmlPullParserUtil.isEndTag(xmlParser, TtmlNode.TAG_METADATA)); + } + /** * Parses a region declaration. * - *

If the region defines an origin and extent, it is required that they're defined as - * percentages of the viewport. Region declarations that define origin and extent in other formats - * are unsupported, and null is returned. + *

Supports both percentage and pixel defined regions. In case of pixel defined regions the + * passed {@code ttsExtent} is used as a reference window to convert the pixel values to + * fractions. In case of missing tts:extent the pixel defined regions can't be parsed, and null is + * returned. */ - private TtmlRegion parseRegionAttributes(XmlPullParser xmlParser, CellResolution cellResolution) { + private TtmlRegion parseRegionAttributes( + XmlPullParser xmlParser, CellResolution cellResolution, TtsExtent ttsExtent) { String regionId = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_ID); if (regionId == null) { return null; @@ -270,13 +317,30 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { float position; float line; + String regionOrigin = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_ORIGIN); if (regionOrigin != null) { - Matcher originMatcher = PERCENTAGE_COORDINATES.matcher(regionOrigin); - if (originMatcher.matches()) { + Matcher originPercentageMatcher = PERCENTAGE_COORDINATES.matcher(regionOrigin); + Matcher originPixelMatcher = PIXEL_COORDINATES.matcher(regionOrigin); + if (originPercentageMatcher.matches()) { try { - position = Float.parseFloat(originMatcher.group(1)) / 100f; - line = Float.parseFloat(originMatcher.group(2)) / 100f; + position = Float.parseFloat(originPercentageMatcher.group(1)) / 100f; + line = Float.parseFloat(originPercentageMatcher.group(2)) / 100f; + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring region with malformed origin: " + regionOrigin); + return null; + } + } else if (originPixelMatcher.matches()) { + if (ttsExtent == null) { + Log.w(TAG, "Ignoring region with missing tts:extent: " + regionOrigin); + return null; + } + try { + int width = Integer.parseInt(originPixelMatcher.group(1)); + int height = Integer.parseInt(originPixelMatcher.group(2)); + // Convert pixel values to fractions. + position = width / (float) ttsExtent.width; + line = height / (float) ttsExtent.height; } catch (NumberFormatException e) { Log.w(TAG, "Ignoring region with malformed origin: " + regionOrigin); return null; @@ -299,11 +363,27 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { float height; String regionExtent = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_EXTENT); if (regionExtent != null) { - Matcher extentMatcher = PERCENTAGE_COORDINATES.matcher(regionExtent); - if (extentMatcher.matches()) { + Matcher extentPercentageMatcher = PERCENTAGE_COORDINATES.matcher(regionExtent); + Matcher extentPixelMatcher = PIXEL_COORDINATES.matcher(regionExtent); + if (extentPercentageMatcher.matches()) { try { - width = Float.parseFloat(extentMatcher.group(1)) / 100f; - height = Float.parseFloat(extentMatcher.group(2)) / 100f; + width = Float.parseFloat(extentPercentageMatcher.group(1)) / 100f; + height = Float.parseFloat(extentPercentageMatcher.group(2)) / 100f; + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring region with malformed extent: " + regionOrigin); + return null; + } + } else if (extentPixelMatcher.matches()) { + if (ttsExtent == null) { + Log.w(TAG, "Ignoring region with missing tts:extent: " + regionOrigin); + return null; + } + try { + int extentWidth = Integer.parseInt(extentPixelMatcher.group(1)); + int extentHeight = Integer.parseInt(extentPixelMatcher.group(2)); + // Convert pixel values to fractions. + width = extentWidth / (float) ttsExtent.width; + height = extentHeight / (float) ttsExtent.height; } catch (NumberFormatException e) { Log.w(TAG, "Ignoring region with malformed extent: " + regionOrigin); return null; @@ -457,6 +537,7 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { long startTime = C.TIME_UNSET; long endTime = C.TIME_UNSET; String regionId = TtmlNode.ANONYMOUS_REGION_ID; + String imageId = null; String[] styleIds = null; int attributeCount = parser.getAttributeCount(); TtmlStyle style = parseStyleAttributes(parser, null); @@ -487,6 +568,13 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { regionId = value; } break; + case ATTR_IMAGE: + // Parse URI reference only if refers to an element in the same document (it must start + // with '#'). Resolving URIs from external sources is not supported. + if (value.startsWith("#")) { + imageId = value.substring(1); + } + break; default: // Do nothing. break; @@ -509,7 +597,8 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { endTime = parent.endTimeUs; } } - return TtmlNode.buildNode(parser.getName(), startTime, endTime, style, styleIds, regionId); + return TtmlNode.buildNode( + parser.getName(), startTime, endTime, style, styleIds, regionId, imageId); } private static boolean isSupportedTag(String tag) { @@ -525,9 +614,9 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { || tag.equals(TtmlNode.TAG_LAYOUT) || tag.equals(TtmlNode.TAG_REGION) || tag.equals(TtmlNode.TAG_METADATA) - || tag.equals(TtmlNode.TAG_SMPTE_IMAGE) - || tag.equals(TtmlNode.TAG_SMPTE_DATA) - || tag.equals(TtmlNode.TAG_SMPTE_INFORMATION); + || tag.equals(TtmlNode.TAG_IMAGE) + || tag.equals(TtmlNode.TAG_DATA) + || tag.equals(TtmlNode.TAG_INFORMATION); } private static void parseFontSize(String expression, TtmlStyle out) throws @@ -651,4 +740,15 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { this.rows = rows; } } + + /** Represents the tts:extent for a TTML file. */ + private static final class TtsExtent { + final int width; + final int height; + + TtsExtent(int width, int height) { + this.width = width; + this.height = height; + } + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java index c8b9a59de4..020bbe201b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java @@ -15,7 +15,12 @@ */ package com.google.android.exoplayer2.text.ttml; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.support.annotation.Nullable; import android.text.SpannableStringBuilder; +import android.util.Base64; +import android.util.Pair; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.util.Assertions; @@ -44,9 +49,9 @@ import java.util.TreeSet; public static final String TAG_LAYOUT = "layout"; public static final String TAG_REGION = "region"; public static final String TAG_METADATA = "metadata"; - public static final String TAG_SMPTE_IMAGE = "smpte:image"; - public static final String TAG_SMPTE_DATA = "smpte:data"; - public static final String TAG_SMPTE_INFORMATION = "smpte:information"; + public static final String TAG_IMAGE = "image"; + public static final String TAG_DATA = "data"; + public static final String TAG_INFORMATION = "information"; public static final String ANONYMOUS_REGION_ID = ""; public static final String ATTR_ID = "id"; @@ -75,34 +80,57 @@ import java.util.TreeSet; public static final String START = "start"; public static final String END = "end"; - public final String tag; - public final String text; + @Nullable public final String tag; + @Nullable public final String text; public final boolean isTextNode; public final long startTimeUs; public final long endTimeUs; - public final TtmlStyle style; + @Nullable public final TtmlStyle style; + @Nullable private final String[] styleIds; public final String regionId; + @Nullable public final String imageId; - private final String[] styleIds; private final HashMap nodeStartsByRegion; private final HashMap nodeEndsByRegion; private List children; public static TtmlNode buildTextNode(String text) { - return new TtmlNode(null, TtmlRenderUtil.applyTextElementSpacePolicy(text), C.TIME_UNSET, - C.TIME_UNSET, null, null, ANONYMOUS_REGION_ID); + return new TtmlNode( + /* tag= */ null, + TtmlRenderUtil.applyTextElementSpacePolicy(text), + /* startTimeUs= */ C.TIME_UNSET, + /* endTimeUs= */ C.TIME_UNSET, + /* style= */ null, + /* styleIds= */ null, + ANONYMOUS_REGION_ID, + /* imageId= */ null); } - public static TtmlNode buildNode(String tag, long startTimeUs, long endTimeUs, - TtmlStyle style, String[] styleIds, String regionId) { - return new TtmlNode(tag, null, startTimeUs, endTimeUs, style, styleIds, regionId); + public static TtmlNode buildNode( + @Nullable String tag, + long startTimeUs, + long endTimeUs, + @Nullable TtmlStyle style, + @Nullable String[] styleIds, + String regionId, + @Nullable String imageId) { + return new TtmlNode( + tag, /* text= */ null, startTimeUs, endTimeUs, style, styleIds, regionId, imageId); } - private TtmlNode(String tag, String text, long startTimeUs, long endTimeUs, - TtmlStyle style, String[] styleIds, String regionId) { + private TtmlNode( + @Nullable String tag, + @Nullable String text, + long startTimeUs, + long endTimeUs, + @Nullable TtmlStyle style, + @Nullable String[] styleIds, + String regionId, + @Nullable String imageId) { this.tag = tag; this.text = text; + this.imageId = imageId; this.style = style; this.styleIds = styleIds; this.isTextNode = text != null; @@ -151,7 +179,8 @@ import java.util.TreeSet; private void getEventTimes(TreeSet out, boolean descendsPNode) { boolean isPNode = TAG_P.equals(tag); - if (descendsPNode || isPNode) { + boolean isDivNode = TAG_DIV.equals(tag); + if (descendsPNode || isPNode || (isDivNode && imageId != null)) { if (startTimeUs != C.TIME_UNSET) { out.add(startTimeUs); } @@ -171,13 +200,46 @@ import java.util.TreeSet; return styleIds; } - public List getCues(long timeUs, Map globalStyles, - Map regionMap) { - TreeMap regionOutputs = new TreeMap<>(); - traverseForText(timeUs, false, regionId, regionOutputs); - traverseForStyle(timeUs, globalStyles, regionOutputs); + public List getCues( + long timeUs, + Map globalStyles, + Map regionMap, + Map imageMap) { + + List> regionImageOutputs = new ArrayList<>(); + traverseForImage(timeUs, regionId, regionImageOutputs); + + TreeMap regionTextOutputs = new TreeMap<>(); + traverseForText(timeUs, false, regionId, regionTextOutputs); + traverseForStyle(timeUs, globalStyles, regionTextOutputs); + List cues = new ArrayList<>(); - for (Entry entry : regionOutputs.entrySet()) { + + // Create image based cues. + for (Pair regionImagePair : regionImageOutputs) { + String encodedBitmapData = imageMap.get(regionImagePair.second); + if (encodedBitmapData == null) { + // Image reference points to an invalid image. Do nothing. + continue; + } + + byte[] bitmapData = Base64.decode(encodedBitmapData, Base64.DEFAULT); + Bitmap bitmap = BitmapFactory.decodeByteArray(bitmapData, /* offset= */ 0, bitmapData.length); + TtmlRegion region = regionMap.get(regionImagePair.first); + + cues.add( + new Cue( + bitmap, + region.position, + Cue.ANCHOR_TYPE_MIDDLE, + region.line, + region.lineAnchor, + region.width, + /* height= */ Cue.DIMEN_UNSET)); + } + + // Create text based cues. + for (Entry entry : regionTextOutputs.entrySet()) { TtmlRegion region = regionMap.get(entry.getKey()); cues.add( new Cue( @@ -192,9 +254,22 @@ import java.util.TreeSet; region.textSizeType, region.textSize)); } + return cues; } + private void traverseForImage( + long timeUs, String inheritedRegion, List> regionImageList) { + String resolvedRegionId = ANONYMOUS_REGION_ID.equals(regionId) ? inheritedRegion : regionId; + if (isActive(timeUs) && TAG_DIV.equals(tag) && imageId != null) { + regionImageList.add(new Pair<>(resolvedRegionId, imageId)); + return; + } + for (int i = 0; i < getChildCount(); ++i) { + getChild(i).traverseForImage(timeUs, resolvedRegionId, regionImageList); + } + } + private void traverseForText( long timeUs, boolean descendsPNode, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlSubtitle.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlSubtitle.java index 50916aa841..7b30461750 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlSubtitle.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlSubtitle.java @@ -32,11 +32,16 @@ import java.util.Map; private final long[] eventTimesUs; private final Map globalStyles; private final Map regionMap; + private final Map imageMap; - public TtmlSubtitle(TtmlNode root, Map globalStyles, - Map regionMap) { + public TtmlSubtitle( + TtmlNode root, + Map globalStyles, + Map regionMap, + Map imageMap) { this.root = root; this.regionMap = regionMap; + this.imageMap = imageMap; this.globalStyles = globalStyles != null ? Collections.unmodifiableMap(globalStyles) : Collections.emptyMap(); this.eventTimesUs = root.getEventTimesUs(); @@ -65,7 +70,7 @@ import java.util.Map; @Override public List getCues(long timeUs) { - return root.getCues(timeUs, globalStyles, regionMap); + return root.getCues(timeUs, globalStyles, regionMap, imageMap); } /* @VisibleForTesting */ diff --git a/library/core/src/test/assets/ttml/bitmap_percentage_region.xml b/library/core/src/test/assets/ttml/bitmap_percentage_region.xml new file mode 100644 index 0000000000..9631650178 --- /dev/null +++ b/library/core/src/test/assets/ttml/bitmap_percentage_region.xml @@ -0,0 +1,26 @@ + + + + + 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 + + + +