-
Notifications
You must be signed in to change notification settings - Fork 6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
SSA/ASS subtitles - Overlapping start/end times and position tag is not handled #6595
Changes from 2 commits
5791677
0391e73
4d6d806
3b741e5
86efd19
7a6de79
925a7fd
fb2a702
0c5d470
3f5654a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
|
@@ -16,14 +16,14 @@ | |||
package com.google.android.exoplayer2.text.ssa; | ||||
|
||||
import android.text.TextUtils; | ||||
import android.util.Pair; | ||||
import androidx.annotation.Nullable; | ||||
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.text.Subtitle; | ||||
import com.google.android.exoplayer2.util.Assertions; | ||||
import com.google.android.exoplayer2.util.Log; | ||||
import com.google.android.exoplayer2.util.LongArray; | ||||
import com.google.android.exoplayer2.util.ParsableByteArray; | ||||
import com.google.android.exoplayer2.util.Util; | ||||
import java.util.ArrayList; | ||||
|
@@ -40,6 +40,9 @@ public final class SsaDecoder extends SimpleSubtitleDecoder { | |||
|
||||
private static final Pattern SSA_TIMECODE_PATTERN = Pattern.compile( | ||||
"(?:(\\d+):)?(\\d+):(\\d+)(?::|\\.)(\\d+)"); | ||||
private static final Pattern SSA_POSITION_PATTERN = Pattern.compile( | ||||
"\\\\pos\\((\\d+(\\.\\d+)?),\\s*(\\d+(\\.\\d+)?)"); | ||||
|
||||
private static final String FORMAT_LINE_PREFIX = "Format: "; | ||||
private static final String DIALOGUE_LINE_PREFIX = "Dialogue: "; | ||||
|
||||
|
@@ -50,6 +53,9 @@ public final class SsaDecoder extends SimpleSubtitleDecoder { | |||
private int formatEndIndex; | ||||
private int formatTextIndex; | ||||
|
||||
private int playResX; | ||||
private int playResY; | ||||
|
||||
public SsaDecoder() { | ||||
this(/* initializationData= */ null); | ||||
} | ||||
|
@@ -75,19 +81,15 @@ public SsaDecoder(@Nullable List<byte[]> initializationData) { | |||
|
||||
@Override | ||||
protected Subtitle decode(byte[] bytes, int length, boolean reset) { | ||||
ArrayList<Cue> cues = new ArrayList<>(); | ||||
LongArray cueTimesUs = new LongArray(); | ||||
ArrayList<List<Cue>> cues = new ArrayList<>(); | ||||
List<Long> cueTimesUs = new ArrayList<>(); | ||||
|
||||
ParsableByteArray data = new ParsableByteArray(bytes, length); | ||||
if (!haveInitializationData) { | ||||
parseHeader(data); | ||||
} | ||||
parseEventBody(data, cues, cueTimesUs); | ||||
|
||||
Cue[] cuesArray = new Cue[cues.size()]; | ||||
cues.toArray(cuesArray); | ||||
long[] cueTimesUsArray = cueTimesUs.toArray(); | ||||
return new SsaSubtitle(cuesArray, cueTimesUsArray); | ||||
return new SsaSubtitle(cues, cueTimesUs); | ||||
} | ||||
|
||||
/** | ||||
|
@@ -98,6 +100,12 @@ protected Subtitle decode(byte[] bytes, int length, boolean reset) { | |||
private void parseHeader(ParsableByteArray data) { | ||||
String currentLine; | ||||
while ((currentLine = data.readLine()) != null) { | ||||
if (currentLine.startsWith("PlayResX:")) { | ||||
playResX = Integer.valueOf(currentLine.substring(9).trim()); | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it's a tiny bit clearer to use the string literal again instead of a 'magic' length (saves a future reader manually counting the number of characters in "PlayResX:" :))
|
||||
} | ||||
if (currentLine.startsWith("PlayResY:")) { | ||||
playResY = Integer.valueOf(currentLine.substring(9).trim()); | ||||
} | ||||
// TODO: Parse useful data from the header. | ||||
if (currentLine.startsWith("[Events]")) { | ||||
// We've reached the event body. | ||||
|
@@ -113,7 +121,7 @@ private void parseHeader(ParsableByteArray data) { | |||
* @param cues A list to which parsed cues will be added. | ||||
* @param cueTimesUs An array to which parsed cue timestamps will be added. | ||||
*/ | ||||
private void parseEventBody(ParsableByteArray data, List<Cue> cues, LongArray cueTimesUs) { | ||||
private void parseEventBody(ParsableByteArray data, List<List<Cue>> cues, List<Long> cueTimesUs) { | ||||
String currentLine; | ||||
while ((currentLine = data.readLine()) != null) { | ||||
if (!haveInitializationData && currentLine.startsWith(FORMAT_LINE_PREFIX)) { | ||||
|
@@ -167,7 +175,7 @@ private void parseFormatLine(String formatLine) { | |||
* @param cues A list to which parsed cues will be added. | ||||
* @param cueTimesUs An array to which parsed cue timestamps will be added. | ||||
*/ | ||||
private void parseDialogueLine(String dialogueLine, List<Cue> cues, LongArray cueTimesUs) { | ||||
private void parseDialogueLine(String dialogueLine, List<List<Cue>> cues, List<Long> cueTimesUs) { | ||||
if (formatKeyCount == 0) { | ||||
Log.w(TAG, "Skipping dialogue line before complete format: " + dialogueLine); | ||||
return; | ||||
|
@@ -196,16 +204,67 @@ private void parseDialogueLine(String dialogueLine, List<Cue> cues, LongArray cu | |||
} | ||||
} | ||||
|
||||
// Parse \pos{x,y} attribute | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: I'd move this comment to the javadoc of |
||||
Pair<Float, Float> position = parsePosition(lineValues[formatTextIndex]); | ||||
|
||||
String text = lineValues[formatTextIndex] | ||||
.replaceAll("\\{.*?\\}", "") | ||||
.replaceAll("\\\\N", "\n") | ||||
.replaceAll("\\\\n", "\n"); | ||||
cues.add(new Cue(text)); | ||||
cueTimesUs.add(startTimeUs); | ||||
|
||||
Cue cue; | ||||
if (position != null && playResX != 0 && playResY != 0) { | ||||
cue = new Cue( | ||||
text, | ||||
/* textAlignment */ null, | ||||
position.second / playResY, | ||||
Cue.LINE_TYPE_FRACTION, | ||||
Cue.ANCHOR_TYPE_START, | ||||
position.first / playResX, | ||||
Cue.ANCHOR_TYPE_MIDDLE, | ||||
Cue.DIMEN_UNSET); | ||||
} else { | ||||
cue = new Cue(text); | ||||
} | ||||
|
||||
int startTimeIndex = insertToCueTimes(cueTimesUs, startTimeUs); | ||||
|
||||
List<Cue> startCueList = new ArrayList<>(); | ||||
if (startTimeIndex != 0) { | ||||
startCueList.addAll(cues.get(startTimeIndex - 1)); | ||||
} | ||||
cues.add(startTimeIndex, startCueList); | ||||
|
||||
if (endTimeUs != C.TIME_UNSET) { | ||||
cues.add(Cue.EMPTY); | ||||
cueTimesUs.add(endTimeUs); | ||||
int endTimeIndex = insertToCueTimes(cueTimesUs, endTimeUs); | ||||
List<Cue> endList = new ArrayList<>(cues.get(endTimeIndex - 1)); | ||||
cues.add(endTimeIndex, endList); | ||||
|
||||
int i = startTimeIndex; | ||||
do { | ||||
cues.get(i).add(cue); | ||||
i++; | ||||
} while (i != endTimeIndex); | ||||
} | ||||
} | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I could be wrong, but I'm not convinced this correctly handles multiple cues that have the same start or end time.
We'll insert the first one, after which we have: Then insert the second one: Whereas I think with this input we'd want these lists: It also took quite a lot of thought for me to follow that through, especially with the multiple mutations to the Note that we have this same overlapping challenge in the webvtt package and we actually solve it in a slightly different way, by more lazily evaluating My suggestion is to get rid of
[1] ExoPlayer/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttSubtitle.java Line 75 in 41b3fc1
|
||||
|
||||
/** | ||||
* Insert the given cue time into the given array keeping the array sorted. | ||||
* | ||||
* @param cueTimes The array with sorted cue times | ||||
* @param timeUs The cue time to be inserted | ||||
* @return The index where the cue time was inserted | ||||
*/ | ||||
private static int insertToCueTimes(List<Long> cueTimes, long timeUs) { | ||||
for (int i = cueTimes.size() - 1; i >= 0; i--) { | ||||
if (cueTimes.get(i) <= timeUs) { | ||||
cueTimes.add(i + 1, timeUs); | ||||
return i + 1; | ||||
} | ||||
} | ||||
|
||||
cueTimes.add(0, timeUs); | ||||
return 0; | ||||
} | ||||
|
||||
/** | ||||
|
@@ -214,7 +273,7 @@ private void parseDialogueLine(String dialogueLine, List<Cue> cues, LongArray cu | |||
* @param timeString The string to parse. | ||||
* @return The parsed timestamp in microseconds. | ||||
*/ | ||||
public static long parseTimecodeUs(String timeString) { | ||||
private static long parseTimecodeUs(String timeString) { | ||||
Matcher matcher = SSA_TIMECODE_PATTERN.matcher(timeString); | ||||
if (!matcher.matches()) { | ||||
return C.TIME_UNSET; | ||||
|
@@ -226,4 +285,15 @@ public static long parseTimecodeUs(String timeString) { | |||
return timestampUs; | ||||
} | ||||
|
||||
@Nullable | ||||
public static Pair<Float, Float> parsePosition(String line){ | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Might make more sense to use android.graphics.PointF here because it's a little less general/ambiguous than Pair<Float, Float> Also avoids auto-boxing |
||||
Matcher matcher = SSA_POSITION_PATTERN.matcher(line); | ||||
if(!matcher.find()){ | ||||
return null; | ||||
} | ||||
float x = Float.parseFloat(matcher.group(1)); | ||||
float y = Float.parseFloat(matcher.group(3)); | ||||
return new Pair<>(x,y); | ||||
} | ||||
|
||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -28,45 +28,44 @@ | |
*/ | ||
/* package */ final class SsaSubtitle implements Subtitle { | ||
|
||
private final Cue[] cues; | ||
private final long[] cueTimesUs; | ||
private final List<List<Cue>> cues; | ||
private final List<Long> cueTimesUs; | ||
|
||
/** | ||
* @param cues The cues in the subtitle. | ||
* @param cueTimesUs The cue times, in microseconds. | ||
*/ | ||
public SsaSubtitle(Cue[] cues, long[] cueTimesUs) { | ||
public SsaSubtitle(List<List<Cue>> cues, List<Long> cueTimesUs) { | ||
this.cues = cues; | ||
this.cueTimesUs = cueTimesUs; | ||
} | ||
|
||
@Override | ||
public int getNextEventTimeIndex(long timeUs) { | ||
int index = Util.binarySearchCeil(cueTimesUs, timeUs, false, false); | ||
return index < cueTimesUs.length ? index : C.INDEX_UNSET; | ||
return index < cueTimesUs.size() ? index : C.INDEX_UNSET; | ||
} | ||
|
||
@Override | ||
public int getEventTimeCount() { | ||
return cueTimesUs.length; | ||
return cueTimesUs.size(); | ||
} | ||
|
||
@Override | ||
public long getEventTime(int index) { | ||
Assertions.checkArgument(index >= 0); | ||
Assertions.checkArgument(index < cueTimesUs.length); | ||
return cueTimesUs[index]; | ||
Assertions.checkArgument(index < cueTimesUs.size()); | ||
return cueTimesUs.get(index); | ||
} | ||
|
||
@Override | ||
public List<Cue> getCues(long timeUs) { | ||
int index = Util.binarySearchFloor(cueTimesUs, timeUs, true, false); | ||
if (index == -1 || cues[index] == Cue.EMPTY) { | ||
if (index == -1 || cues.get(index).isEmpty()) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you can get rid of the empty check, since we'll just return the empty list below anyway (right?) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, that's right. |
||
// timeUs is earlier than the start of the first cue, or we have an empty cue. | ||
return Collections.emptyList(); | ||
} else { | ||
return Collections.singletonList(cues[index]); | ||
return cues.get(index); | ||
} | ||
} | ||
|
||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's probably safer to explicitly initialise these to an invalid value to clearly indicate 'unset', because 0 seems like a potentially genuine value we could see in a subtitle file? And then also update the comparison below on L216.
We have
C.LENGTH_UNSET
which I think would work well.https://github.com/google/ExoPlayer/blob/release-v2/library/core/src/main/java/com/google/android/exoplayer2/C.java