add SSA/ASS specific files from branch

This commit is contained in:
Julian Cable 2017-01-02 14:07:29 +00:00
parent 163a3a7bb8
commit 246a0dc86e
4 changed files with 688 additions and 0 deletions

View File

@ -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;
}
}

View File

@ -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<String,Style> 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<String,String> 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<String,String> 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<String, Style> 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<r.length; i++) {
r[i] = keys[i].trim().toLowerCase();
}
return r;
}
public static Map<String,String> parseLine(String[] keys, String event) {
Map<String,String> result = new HashMap<>();
String fields[] = event.split(", *", keys.length);
for(int i=0; i<keys.length; i++) {
String k = keys[i];
String v = fields[i].trim();
result.put(k, v);
}
return result;
}
public static void writeMangledHeader(StringBuffer s, byte[] data){
// header contains the original format but the Matroska encoder changes this.
// we won't need anything after the [Events] line
try {
String header = new String(data, "UTF-8").split("\\[Events]")[0];
s.append(header);
}
catch (UnsupportedEncodingException e) {
// we know this can't happen
}
s.append("[Events]\n");
s.append(defaultDialogueFormat);
s.append("\n");
}
public static void buildDialogue(StringBuffer s, String data, long durationUs) {
s.append("Dialogue: ");
s.append(SSADecoder.formatTimeCode(0)); // blockTimeUs
s.append(",");
long endUs = durationUs; // + blockTimeUs
if (endUs == C.TIME_UNSET) {
endUs = 2000000; // 2 second default duration
}
s.append(SSADecoder.formatTimeCode(endUs));
s.append(",");
s.append(data);
s.append("\n");
}
public static String formatTimeCode(long tc_us) {
long seconds = tc_us / 1000000;
long us = tc_us - 1000000*seconds;
long minutes = seconds / 60;
seconds -= 60 * minutes;
long hours = minutes / 60;
minutes -= 60*hours;
double sec = seconds + ((float)us)/1000000.0;
return String.format("%01d:%02d:%06.3f", hours, minutes, sec);
}
public static long parseTimecode(String time) {
String p[] = time.split(":");
long hours = Long.parseLong(p[0]);
long minutes = Long.parseLong(p[1]);
float seconds = Float.parseFloat(p[2]);
float us = 1000000*seconds;
long lus = ((long)us);
return lus + 1000000 * (60 * (minutes + 60 * hours));
}
}

View File

@ -0,0 +1,102 @@
package com.google.android.exoplayer2.text.ssa;
import android.text.Layout;
import android.util.Log;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.text.Cue;
import com.google.android.exoplayer2.text.Subtitle;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.LongArray;
import com.google.android.exoplayer2.util.Util;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static android.R.attr.start;
/**
* Created by cablej01 on 26/12/2016.
*/
public class SSASubtitle implements Subtitle {
private List<Cue> cues = new ArrayList<>();
private List<Long> 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<Cue> 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<String,String> ev, Map<String,Style> 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);
}
}

View File

@ -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<String,String> 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;
}
}