From 246a0dc86ef4283a7d6d2efcfaf2b375c4c82cb2 Mon Sep 17 00:00:00 2001 From: Julian Cable Date: Mon, 2 Jan 2017 14:07:29 +0000 Subject: [PATCH] add SSA/ASS specific files from branch --- .../android/exoplayer2/text/ssa/SSACue.java | 43 +++ .../exoplayer2/text/ssa/SSADecoder.java | 260 ++++++++++++++++ .../exoplayer2/text/ssa/SSASubtitle.java | 102 +++++++ .../android/exoplayer2/text/ssa/Style.java | 283 ++++++++++++++++++ 4 files changed, 688 insertions(+) create mode 100644 library/src/main/java/com/google/android/exoplayer2/text/ssa/SSACue.java create mode 100644 library/src/main/java/com/google/android/exoplayer2/text/ssa/SSADecoder.java create mode 100644 library/src/main/java/com/google/android/exoplayer2/text/ssa/SSASubtitle.java create mode 100644 library/src/main/java/com/google/android/exoplayer2/text/ssa/Style.java diff --git a/library/src/main/java/com/google/android/exoplayer2/text/ssa/SSACue.java b/library/src/main/java/com/google/android/exoplayer2/text/ssa/SSACue.java new file mode 100644 index 0000000000..e60925b261 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/text/ssa/SSACue.java @@ -0,0 +1,43 @@ +package com.google.android.exoplayer2.text.ssa; + +import com.google.android.exoplayer2.text.Cue; + +/** + * Created by cablej01 on 02/01/2017. + */ + +public class SSACue extends Cue { + private Style style = null; + private int layer; + private String effect; + private String richText = null; + + public SSACue(String text) { + this(text, null, 0, null); + } + + public SSACue(String text, Style style, int layer, String effect) { + super(text.replaceAll("\\{[^{]*\\}", "")); + this.richText = text; + this.layer = layer; + this.effect = effect; + this.style = style; + // TODO map SSA fields to superclass fields + } + + public Style getStyle() { + return style; + } + + public int getLayer() { + return layer; + } + + public String getEffect() { + return effect; + } + + public String getRichText() { + return richText; + } +} diff --git a/library/src/main/java/com/google/android/exoplayer2/text/ssa/SSADecoder.java b/library/src/main/java/com/google/android/exoplayer2/text/ssa/SSADecoder.java new file mode 100644 index 0000000000..1e16ef8ef1 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/text/ssa/SSADecoder.java @@ -0,0 +1,260 @@ +package com.google.android.exoplayer2.text.ssa; + +import android.content.Intent; +import android.text.Layout; +import android.util.ArrayMap; +import android.util.Log; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.text.Cue; +import com.google.android.exoplayer2.text.SimpleSubtitleDecoder; +import com.google.android.exoplayer2.util.LongArray; +import com.google.android.exoplayer2.util.ParsableBitArray; +import com.google.android.exoplayer2.util.ParsableByteArray; + +import java.io.UnsupportedEncodingException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static android.R.attr.breadCrumbShortTitle; +import static android.R.attr.data; +import static android.R.attr.format; +import static android.R.attr.key; +import static android.R.attr.lines; +import static android.R.attr.subtitle; +import static android.R.attr.text; +import static android.R.attr.textAlignment; +import static android.R.attr.track; +import static android.icu.lang.UCharacter.GraphemeClusterBreak.L; +import static android.webkit.ConsoleMessage.MessageLevel.LOG; +import static com.google.android.exoplayer2.text.Cue.DIMEN_UNSET; +import static com.google.android.exoplayer2.text.Cue.TYPE_UNSET; + +/** + * Created by cablej01 on 26/12/2016. + */ + +/* Notes from ojw28 + + Subtitles are really complicated because they can be packaged in different units of granularity + and with different ways of conveying timing information. Roughly speaking, an input buffer + received by a subtitle decoder consists of a timestamp (timeUs) and the subtitle data to be + decoded (data). There are four cases that can occur: + + 1. data contains all of the cues for the media and also their presentation timestamps. + timeUs is the time of the start of the media. The subtitle decoder receives a single input buffer. + + 2. data contains a single cue to be displayed at timeUs. There are no timestamps encoded in data. + The subtitle decoder receives many input buffers. + + 3. data contains cues covering a region of time (e.g. 5 seconds) along with their presentation + timestamps relative to the start of the region. timeUs is the time of the start of the region. + The subtitle decoder receives many input buffers. + + 4. As above, but the timestamps embedded in data are relative to the start of the media rather + than the start of the region. This case is tricky and best avoided. + + For a side-loaded SSA file you'd have case (1). + + For SSA embedded in MKV, it looks like they way it's embedded means you'd have case (2) + if you were to just pass the sample data through without changing it. + Note that timeUs is being set to blockTimeUs already. + Each region happens to be the duration of a single cue. + + In the extractor, It's much easier to handle if you change the sample data so that you get case (3). + This basically means the embedded time should be 0 rather than blockTimeUs. + + If you look at the SubRip case in the MKV extractor you'll see that it does exactly this. + The SubRip case also defers writing so that the end time can be set properly. + + In the decoder you should create a new Subtitle instance for each decode call, rather than appending to an existing instance. + + For the SSA embedded in MKV case you should end up with each call to decode producing a new Subtitle with a single cue at time 0. + The reason this works is that the event timing in a Subtitle is relative to timeUs of the buffer, + which is being set to blockTimeUs. When the decoder receives a new input buffer with a larger timeUs + than the previous one, the value passed to getCues will go down. + */ + + +public class SSADecoder extends SimpleSubtitleDecoder { + private static final String TAG = "SSADecoder"; + private static String defaultDialogueFormat = "Start, End, , Layer, Style, Name, MarginL, MarginR, MarginV, Effect, Text"; + private static String defaultStyleFormat = "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding"; + private String[] dialogueFormat; + private String[] styleFormat; + private Map styles = new HashMap<>(); + + public SSADecoder() { + super("SSADecoder"); + dialogueFormat = parseKeys(defaultDialogueFormat); + styleFormat = parseKeys(defaultStyleFormat); + } + + /** + * Decodes data into a {@link SSASubtitle}. + * + * @param bytes An array holding the data to be decoded, starting at position 0. + * @param length The size of the data to be decoded. + * @return The decoded {@link SSASubtitle}. + */ + @Override + protected SSASubtitle decode(byte[] bytes, int length) { + SSASubtitle subtitle = new SSASubtitle(); + ParsableByteArray data = new ParsableByteArray(bytes, length); + String currentLine; + while ((currentLine = data.readLine()) != null) { + if (currentLine.matches("^Dialogue:.*$")) { + String p[] = currentLine.split(":",2); + Map ev = parseLine(dialogueFormat, p[1].trim()); + subtitle.addEvent(ev, styles); + } + } + return subtitle; + } + + public void decodeFile(byte[] bytes, int length) { + SSASubtitle subtitle = new SSASubtitle(); + ParsableByteArray data = new ParsableByteArray(bytes, length); + decodeHeader(data); + String currentLine; + while ((currentLine = data.readLine()) != null) { + while(true) { + currentLine = data.readLine(); + if(currentLine==null) + break; + Log.i(TAG, currentLine); + if(!currentLine.contains(":")) + break; + String p[] = currentLine.split(":",2); + if(p[0].equals("Format")) { + dialogueFormat = parseKeys(p[1]); + } + else if(p[0].equals("Dialogue")) { + Map ev = parseLine(dialogueFormat, p[1].trim()); + subtitle.addEvent(ev, styles); + } + } + } + } + + public void decodeHeader(byte[] bytes, int length) { + ParsableByteArray data = new ParsableByteArray(bytes, length); + decodeHeader(data); + } + + private void decodeHeader(ParsableByteArray data) { + String currentLine; + while ((currentLine = data.readLine()) != null) { + if (currentLine.length() == 0) { + // Skip blank lines. + continue; + } + Log.i(TAG, currentLine); + + if (currentLine.equals("[Script Info]")) { + // TODO + continue; + } else if (currentLine.equals("[V4+ Styles]")) { + parseStyles(styles, data); + continue; + } else if (currentLine.equals("[V4 Styles]")) { + parseStyles(styles, data); + continue; + } else if (currentLine.equals("[Events]")) { + break; + } + } + } + + private void parseStyles(Map styles, ParsableByteArray data) { + while(true) { + String line = data.readLine(); + if(line==null) + break; + Log.i(TAG, line); + if(!line.contains(":")) + break; + String p[] = line.split(":",2); + if(p[0].equals("Format")) { + styleFormat = parseKeys(p[1]); + } + else if(p[0].equals("Style")) { + Style s = new Style(parseLine(styleFormat, p[1])); + styles.put(s.getName(), s); + } + } + } + + private String[] parseKeys(String format) { + String keys[] = format.split(", *"); + String r[] = new String[keys.length]; + for(int i=0; i parseLine(String[] keys, String event) { + Map result = new HashMap<>(); + String fields[] = event.split(", *", keys.length); + for(int i=0; i cues = new ArrayList<>(); + private List cueTimesUs = new ArrayList<>(); + + + public SSASubtitle() { + super(); + } + + public void add(int pos, Cue cue, long cueTimeUs) { + cues.add(pos, cue); + cueTimesUs.add(pos, cueTimeUs); + } + + @Override + public int getNextEventTimeIndex(long timeUs) { + int index = Util.binarySearchCeil(cueTimesUs, timeUs, false, false); + return index < cueTimesUs.size() ? index : C.INDEX_UNSET; + } + + @Override + public int getEventTimeCount() { + return cueTimesUs.size(); + } + + @Override + public long getEventTime(int index) { + Assertions.checkArgument(index >= 0); + Assertions.checkArgument(index < cueTimesUs.size()); + return cueTimesUs.get(index); + } + + @Override + public List getCues(long timeUs) { + Log.i("getCues", String.format("%d %s", timeUs, SSADecoder.formatTimeCode(timeUs))); + int index = Util.binarySearchFloor(cueTimesUs, timeUs, true, false); + if (index == -1 || cues.get(index) == null) { + // timeUs is earlier than the start of the first cue, or we have an empty cue. + return Collections.emptyList(); + } else { + return Collections.singletonList(cues.get(index)); + } + } + + protected void addEvent(Map ev, Map styles) { + // int readOrder = Integer.parseInt(ev.get("readorder")); ? not needed + int marginL = Integer.parseInt(ev.get("marginl")); + int marginR = Integer.parseInt(ev.get("marginr")); + int marginV = Integer.parseInt(ev.get("marginv")); + String styleName = ev.get("style"); + Style style = styles.get(styleName); + if(marginL!=0 || marginR!=0 || marginV !=0) { + style = new Style(style); + } + if(marginL!=0) { + style.setMarginL(marginL); + } + if(marginR!=0) { + style.setMarginR(marginR); + } + if(marginV!=0) { + style.setMarginV(marginV); + } + int layer = Integer.parseInt(ev.get("layer")); + String effect = ev.get("effect"); + String text = ev.get("text").replaceAll("\\\\N", "\n"); + String simpleText = text.replaceAll("\\{[^{]*\\}", ""); + Cue cue = new SSACue(text, style, layer, effect); + long start = SSADecoder.parseTimecode(ev.get("start")); + cueTimesUs.add(start); + cues.add(cue); + // add null cue to remove this cue after it's duration + long end = SSADecoder.parseTimecode(ev.get("end")); + cueTimesUs.add(end); + cues.add(null); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/text/ssa/Style.java b/library/src/main/java/com/google/android/exoplayer2/text/ssa/Style.java new file mode 100644 index 0000000000..87dd8df704 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/text/ssa/Style.java @@ -0,0 +1,283 @@ +package com.google.android.exoplayer2.text.ssa; + +import android.graphics.Outline; + +import java.util.Map; + +import static android.os.Build.VERSION_CODES.M; + +/** + * Created by cablej01 on 27/12/2016. + */ + +public class Style { + private String name; + private String fontName; + private int fontSize; + private int primaryColour, secondaryColour, outlineColour, backColour; + private boolean bold, italic, underline, strikeOut; + private int scaleX, scaleY, spacing, angle; + private int borderStyle; + private int outline, shadow, alignment, marginL, marginR, marginV; + private int alphaLevel=0; + private int encoding; + + public Style() { + + } + + public Style(Map init) { + name = init.get("name"); + fontName = init.get("fontname"); + fontSize = Integer.parseInt(init.get("fontsize")); + primaryColour = parseColour(init.get("primarycolour")); + secondaryColour = parseColour(init.get("secondarycolour")); + outlineColour = parseColour(init.get("outlinecolour")); + backColour = parseColour(init.get("backcolour")); + bold = init.get("bold").equals("0")?false:true; + italic = init.get("italic").equals("0")?false:true; + underline = init.get("underline").equals("0")?false:true; + strikeOut = init.get("strikeout").equals("0")?false:true; + scaleX = Integer.parseInt(init.get("scalex")); + scaleY = Integer.parseInt(init.get("scaley")); + spacing = Integer.parseInt(init.get("spacing")); + angle = Integer.parseInt(init.get("angle")); + borderStyle = Integer.parseInt(init.get("borderstyle")); + outline = Integer.parseInt(init.get("outline")); + shadow = Integer.parseInt(init.get("shadow")); + alignment = Integer.parseInt(init.get("alignment")); + marginL = Integer.parseInt(init.get("marginl")); + marginR = Integer.parseInt(init.get("marginr")); + marginV = Integer.parseInt(init.get("marginv")); + if(init.containsKey("alphalevel")) + alphaLevel= Integer.parseInt(init.get("alphalevel")); + encoding = Integer.parseInt(init.get("encoding")); + } + + public Style(Style aStyle) { + name = aStyle.name; + fontName = aStyle.fontName; + fontSize = aStyle.fontSize; + primaryColour = aStyle.primaryColour; + secondaryColour = aStyle.secondaryColour; + outlineColour = aStyle.outlineColour; + backColour = aStyle.backColour; + bold = aStyle.bold; + italic = aStyle.italic; + underline = aStyle.underline; + strikeOut = aStyle.strikeOut; + scaleX = aStyle.scaleX; + scaleY = aStyle.scaleY; + spacing = aStyle.spacing; + angle = aStyle.angle; + borderStyle = aStyle.borderStyle; + outline = aStyle.outline; + shadow = aStyle.shadow; + alignment = aStyle.alignment; + marginL = aStyle.marginL; + marginR = aStyle.marginR; + marginV = aStyle.marginV; + alphaLevel= aStyle.alphaLevel; + encoding = aStyle.encoding; + } + + public static int parseColour(String val) { + return Integer.parseInt(val.substring(2), 16); + } + + public static String formatColour(int val) { + return String.format("&H%06X", val); + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getFontName() { + return fontName; + } + + public void setFontName(String fontName) { + this.fontName = fontName; + } + + public int getFontSize() { + return fontSize; + } + + public void setFontSize(int fontSize) { + this.fontSize = fontSize; + } + + public int getPrimaryColour() { + return primaryColour; + } + + public void setPrimaryColour(int primaryColour) { + this.primaryColour = primaryColour; + } + + public int getSecondaryColour() { + return secondaryColour; + } + + public void setSecondaryColour(int secondaryColour) { + this.secondaryColour = secondaryColour; + } + + public int getOutlineColour() { + return outlineColour; + } + + public void setOutlineColour(int outlineColour) { + this.outlineColour = outlineColour; + } + + public int getBackColour() { + return backColour; + } + + public void setBackColour(int backColour) { + this.backColour = backColour; + } + + public boolean isBold() { + return bold; + } + + public void setBold(boolean bold) { + this.bold = bold; + } + + public boolean isItalic() { + return italic; + } + + public void setItalic(boolean italic) { + this.italic = italic; + } + + public boolean isUnderline() { + return underline; + } + + public void setUnderline(boolean underline) { + this.underline = underline; + } + + public boolean isStrikeOut() { + return strikeOut; + } + + public void setStrikeOut(boolean strikeOut) { + this.strikeOut = strikeOut; + } + + public int getScaleX() { + return scaleX; + } + + public void setScaleX(int scaleX) { + this.scaleX = scaleX; + } + + public int getScaleY() { + return scaleY; + } + + public void setScaleY(int scaleY) { + this.scaleY = scaleY; + } + + public int getSpacing() { + return spacing; + } + + public void setSpacing(int spacing) { + this.spacing = spacing; + } + + public int getAngle() { + return angle; + } + + public void setAngle(int angle) { + this.angle = angle; + } + + public int getBorderStyle() { + return borderStyle; + } + + public void setBorderStyle(int borderStyle) { + this.borderStyle = borderStyle; + } + + public int getOutline() { + return outline; + } + + public void setOutline(int outline) { + this.outline = outline; + } + + public int getShadow() { + return shadow; + } + + public void setShadow(int shadow) { + this.shadow = shadow; + } + + public int getAlignment() { + return alignment; + } + + public void setAlignment(int alignment) { + this.alignment = alignment; + } + + public int getMarginL() { + return marginL; + } + + public void setMarginL(int marginL) { + this.marginL = marginL; + } + + public int getMarginR() { + return marginR; + } + + public void setMarginR(int marginR) { + this.marginR = marginR; + } + + public int getMarginV() { + return marginV; + } + + public void setMarginV(int marginV) { + this.marginV = marginV; + } + + public int getAlphaLevel() { + return alphaLevel; + } + + public void setAlphaLevel(int alphaLevel) { + this.alphaLevel = alphaLevel; + } + + public int getEncoding() { + return encoding; + } + + public void setEncoding(int encoding) { + this.encoding = encoding; + } +}