expo-video-subtitle 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/FORK.md +2 -2
- package/android/src/main/java/expo/modules/video/utils/AssSignOverlay.kt +185 -94
- package/android/src/main/java/expo/modules/video/utils/AssSignParser.kt +52 -9
- package/android/src/main/java/expo/modules/video/utils/AssSignStore.kt +58 -0
- package/android/src/main/java/expo/modules/video/utils/RoundedSubtitleBackground.kt +0 -3
- package/android/src/main/java/expo/modules/video/utils/StackingSubtitleParser.kt +48 -52
- package/package.json +1 -1
- package/android/src/main/java/expo/modules/video/utils/AssSignSpan.kt +0 -23
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,61 @@ Changes inherited from the upstream base version are not repeated here — see t
|
|
|
7
7
|
[upstream changelog](https://github.com/expo/expo/blob/main/packages/expo-video/CHANGELOG.md)
|
|
8
8
|
for the history of everything that isn't subtitle related.
|
|
9
9
|
|
|
10
|
+
## 0.7.2
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Signs did not render at all (Android).** 0.7.0 handed each sign to the overlay on a custom span
|
|
15
|
+
attached to a cue. Nothing survives that trip: `SubtitleExtractor` writes every `CuesWithTiming`
|
|
16
|
+
into the sample queue through `CueEncoder`, which calls `Cue.toSerializableBundle()` and then
|
|
17
|
+
`Parcel.marshall()`, and a span is only preserved if it is one of the three Media3 declares in
|
|
18
|
+
`CustomSpanBundler` or a framework `ParcelableSpan`. Anything else is dropped silently. The cue
|
|
19
|
+
arrived on the other side as the zero-width placeholder with nothing attached, so the overlay found
|
|
20
|
+
no signs — no crash, no log, just missing typesetting while the dialogue rendered normally.
|
|
21
|
+
|
|
22
|
+
Signs now go to `AssSignStore`, keyed by the track's `Format.id`, and the overlay reads the
|
|
23
|
+
player's position on each frame to decide what is on screen. That also removes two problems the
|
|
24
|
+
cue-stream route carried with it: 12,616 sign events would have put ~26,000 boundaries through the
|
|
25
|
+
timeline pass in `StackingSubtitleParser` — an O(n²) sweep over every entry at every boundary — and
|
|
26
|
+
written ~26,000 samples into the queue. The dialogue-only file left for Media3 holds 377 events.
|
|
27
|
+
|
|
28
|
+
- **Scale was applied between the two rotations instead of before both.** Android's `Matrix.post*`
|
|
29
|
+
methods pre-multiply, so the calls apply in the order they are written, and the camera step ran
|
|
30
|
+
first. ASS scales a glyph and then turns it; with an anisotropic scale — this episode carries
|
|
31
|
+
`\fscx125.86` against `\fscy120.19` — the two do not commute, so the shape itself was wrong.
|
|
32
|
+
|
|
33
|
+
- **The overlay re-laid-out every sign whenever any one of them changed.** Frame-by-frame typesetting
|
|
34
|
+
changes the on-screen set every 40 ms, so a scene holding ten placards rebuilt all ten `StaticLayout`s
|
|
35
|
+
on every tick. Layouts are now kept by sign identity and only new signs are laid out.
|
|
36
|
+
|
|
37
|
+
- **A timestamp with no hour fell through to flat rendering.** `MM:SS.cc` is legal — Media3's own
|
|
38
|
+
`SSA_TIMECODE_PATTERN` makes the hour optional — but this parser required it and skipped the event,
|
|
39
|
+
which then stayed in the copy handed to Media3. Media3 reads `\pos` perfectly well and drew the
|
|
40
|
+
sign flat, with nothing in the logs to say it had happened.
|
|
41
|
+
|
|
42
|
+
## 0.7.1
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **Playing an `.ass` track crashed the player (Android).** `Regex(""""\\{([^}]*)}""")` in the sign
|
|
47
|
+
parser left the closing brace unescaped. `java.util.regex` accepts that; Android compiles regular
|
|
48
|
+
expressions with **ICU**, which does not, so the pattern threw `PatternSyntaxException` out of a
|
|
49
|
+
static initialiser on the extractor's loading thread and took playback down with it —
|
|
50
|
+
`ExceptionInInitializerError` on the first ASS subtitle the app opened.
|
|
51
|
+
|
|
52
|
+
The whole class of bug is invisible to this module's JVM tests, because the offending pattern
|
|
53
|
+
compiles there. `RegexPortabilityTest` now reads the sources instead and fails on any regex
|
|
54
|
+
literal with a brace that is neither escaped nor inside a character class.
|
|
55
|
+
|
|
56
|
+
- **A subtitle this package cannot read no longer stops the video.** `StackingSsaParser.split` runs
|
|
57
|
+
on the extractor's loading thread, where a thrown exception is not a failed subtitle — it is a
|
|
58
|
+
dead player. It is now wrapped end to end, and a track that fails falls back to Media3's own ASS
|
|
59
|
+
handling, which draws the captions flat. Subtitle files are untrusted scraper output; plain
|
|
60
|
+
captions are the honest failure, a black screen is not.
|
|
61
|
+
|
|
62
|
+
- **A multi-line sign follows its own alignment.** Every block was laid out centred, so the second
|
|
63
|
+
line of a left- or right-anchored placard pulled to the middle.
|
|
64
|
+
|
|
10
65
|
## 0.7.0
|
|
11
66
|
|
|
12
67
|
### Added
|
package/FORK.md
CHANGED
|
@@ -30,8 +30,8 @@ Measured against the published `expo-video@56.1.4` tarball. `+`/`-` are added/re
|
|
|
30
30
|
| `android/…/utils/StackingSubtitleParser.kt` | 342 | `SubtitleParser` that segments the cue timeline and merges simultaneous **unpositioned** cues into one stacked block, plus the `SubtitleParser.Factory` that routes SubRip and ASS/SSA to it and wraps every parser in `FilteringSubtitleParser`. ASS gets there through `SsaCueNormalizer` as the `prepare` step — without it `SsaParser` sets both `line` and `position` on every cue and they would all take the passthrough branch. Assigns each cue a vertical slot it keeps for its whole time on screen — a finished cue's line is held blank (`\u200B`) so the cues above it do not drop. Merges through a `SpannableStringBuilder` so cue spans survive. |
|
|
31
31
|
| `android/…/utils/SsaCueNormalizer.kt` | 93 | Clears the `line`/`position` Media3's `SsaParser` fills in on cues the ASS author never placed, so `bottomOffset` and cue stacking work on `.ass` the way they already do on `.srt`. The test is exact rather than heuristic: those values are a pure function of the cue's anchors, so matching both defaults for the bottom-centre anchors *is* "no positioning tag". Also drops events on a style the author made invisible (`Fontsize 0`, or a primary colour whose inverted alpha byte is `FF`) — that is where typesetters park working notes, and nothing downstream can carry "invisible". |
|
|
32
32
|
| `android/…/utils/AssSignParser.kt` | 505 | Splits an ASS script into the typeset events this package draws and a copy of the script with those lines removed for Media3's parser. One pass, no matching step, so an event is in exactly one of the two outputs and cannot be drawn twice. Pure Kotlin with no Android imports, which is what makes it unit-testable on the JVM. Reads `\pos`, `\move`, `\org`, `\an`, `\frz`, `\frx`, `\fry`, `\fscx/y`, `\fs`, `\c`, `\alpha`, `\b`, `\i`, `\u` and the style columns behind them. The tag scanner takes the longest name first — `\c` must not swallow `\clip`, `\b` must not swallow `\bord`, `\blur` or `\be` — and reads a leading digit as part of the name for `\1c` and `\4a`. |
|
|
33
|
-
| `android/…/utils/AssSignOverlay.kt` |
|
|
34
|
-
| `android/…/utils/
|
|
33
|
+
| `android/…/utils/AssSignOverlay.kt` | 330 | Draws those signs on a canvas in front of the captions, with `Matrix` for rotation and scale and `Camera` for the two 3-D axes. Drives itself from the player's clock on a `Choreographer` callback, because signs do not ride the cue stream and nothing else would tell it when one starts; 40 ms events need frame granularity anyway. **Media3 cannot do this**: `SubtitlePainter` has no rotate, skew, Matrix or Camera call, and `Cue` has no rotation field. Sits in the same parent as `SubtitleView` — the content frame, sized to the video, which is why `\pos` survives letterboxing. Lays text out on change rather than per frame, because frame-by-frame typesetting changes the on-screen set every 40 ms. |
|
|
34
|
+
| `android/…/utils/AssSignStore.kt` | 61 | Hands parsed signs from the parser to the overlay, keyed by the track's `Format.id`. **Not** the cue stream: `CueEncoder` marshals every cue through `Parcel`, and a custom span that is neither a framework `ParcelableSpan` nor one of Media3's three `CustomSpanBundler` types is dropped without a word — which is how 0.7.0 shipped with signs that parsed correctly and never appeared. Staying out of the cue stream also keeps 12,616 sign events from putting ~26,000 boundaries through the O(n²) timeline pass. |
|
|
35
35
|
| `android/…/utils/RoundedSubtitleBackground.kt` | 352 | Draws `subtitleStyle.backgroundRadius` as rounded boxes, which Media3 cannot do at all: it paints `backgroundColor` as a framework `BackgroundColorSpan` and `windowColor` with `canvas.drawRect`, neither takes a radius, and `SubtitlePainter`/`CanvasSubtitleOutput` are package-private final while `SubtitleView` is final. A second `SubtitleView` is inserted behind `PlayerView`'s own, fed from `Player.Listener.onCues`, drawing a `LineBackgroundSpan` per line. **Its `EDGE_TYPE_NONE` is load-bearing** — `SubtitlePainter` copies the cue text for an edge pass and draws both layouts when the edge type is outline/raised/depressed, which would paint the box twice and darken a semi-transparent colour. State hangs off the view's `tag`, never a map in the companion object; see the `find` KDoc for why even a `WeakHashMap` leaks here. |
|
|
36
36
|
| `FORK.md`, `LICENSE` | — | This guide; MIT licence (upstream copyright preserved). |
|
|
37
37
|
| `eslint.config.js` | 4 | Verbatim copy of `expo-module-scripts/templates/eslint.config.js`. **Recreate it if a sync drops it** — `expo-module configure` treats it as an *optional* template and only syncs it when it already exists, so without this file `npm run lint` dies with "ESLint couldn't find an eslint.config.js". |
|
|
@@ -11,12 +11,13 @@ import android.graphics.Typeface
|
|
|
11
11
|
import android.text.Layout
|
|
12
12
|
import android.text.StaticLayout
|
|
13
13
|
import android.text.TextPaint
|
|
14
|
+
import android.view.Choreographer
|
|
14
15
|
import android.view.View
|
|
15
16
|
import android.view.ViewGroup
|
|
16
17
|
import androidx.annotation.OptIn
|
|
18
|
+
import androidx.media3.common.C
|
|
17
19
|
import androidx.media3.common.Player
|
|
18
|
-
import androidx.media3.common.
|
|
19
|
-
import androidx.media3.common.text.CueGroup
|
|
20
|
+
import androidx.media3.common.Tracks
|
|
20
21
|
import androidx.media3.common.util.UnstableApi
|
|
21
22
|
import androidx.media3.ui.PlayerView
|
|
22
23
|
import androidx.media3.ui.SubtitleView
|
|
@@ -25,9 +26,10 @@ import androidx.media3.ui.SubtitleView
|
|
|
25
26
|
* Draws ASS typesetting — signs, placards, on-screen labels — with the transforms the script asks
|
|
26
27
|
* for.
|
|
27
28
|
*
|
|
28
|
-
* `SubtitleView` cannot: `SubtitlePainter` has no
|
|
29
|
-
*
|
|
30
|
-
*
|
|
29
|
+
* `SubtitleView` cannot: `SubtitlePainter` has no call to `rotate`, `skew`, `Matrix` or `Camera`,
|
|
30
|
+
* and `Cue` has no rotation field, only `shearDegrees`, which the painter ignores. So
|
|
31
|
+
* [AssSignParser] takes those events out of the file Media3 parses and leaves them in
|
|
32
|
+
* [AssSignStore], and this view draws them.
|
|
31
33
|
*
|
|
32
34
|
* ### Where it sits
|
|
33
35
|
*
|
|
@@ -36,93 +38,166 @@ import androidx.media3.ui.SubtitleView
|
|
|
36
38
|
* survive letterboxing. `\pos` is in `PlayResX`/`PlayResY` space and maps onto this view's bounds
|
|
37
39
|
* directly. [RoundedSubtitleBackground] takes the same route for the same reason.
|
|
38
40
|
*
|
|
39
|
-
* ### Why
|
|
41
|
+
* ### Why it drives itself from the clock
|
|
40
42
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* drawn *behind* the fill, not as a shadow.
|
|
43
|
+
* Signs do not ride the cue stream — see [AssSignStore] for why they cannot — so nothing tells this
|
|
44
|
+
* view when one begins. It reads the player's position on each frame instead, which is also the only
|
|
45
|
+
* granularity that works: frame-by-frame typesetting writes one event per frame, and a sign on
|
|
46
|
+
* screen for 40 ms has to arrive and leave on the frame it was authored for.
|
|
46
47
|
*/
|
|
47
48
|
@SuppressLint("ViewConstructor")
|
|
48
49
|
@OptIn(UnstableApi::class)
|
|
49
50
|
internal class AssSignOverlay(context: Context) : View(context), Player.Listener {
|
|
50
51
|
|
|
51
52
|
private val camera = Camera()
|
|
52
|
-
private val matrix = Matrix()
|
|
53
53
|
|
|
54
|
-
private var signs: List<AssSign> = emptyList()
|
|
55
|
-
private var prepared: List<PreparedSign> = emptyList()
|
|
56
54
|
private var player: Player? = null
|
|
55
|
+
private var script: AssScript? = null
|
|
56
|
+
|
|
57
|
+
private var active: List<AssSign> = emptyList()
|
|
58
|
+
private var prepared: List<PreparedSign> = emptyList()
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Keyed by identity, because [AssSignParser] produces one instance per event and hands back the
|
|
62
|
+
* same one for every frame it is on screen. Without it, a scene holding ten placards while an
|
|
63
|
+
* eleventh animates would re-lay-out all eleven on every 40 ms tick.
|
|
64
|
+
*/
|
|
65
|
+
private var layouts = HashMap<AssSign, PreparedSign>()
|
|
66
|
+
|
|
67
|
+
private var frameScheduled = false
|
|
68
|
+
private val onFrame = Choreographer.FrameCallback {
|
|
69
|
+
frameScheduled = false
|
|
70
|
+
tick()
|
|
71
|
+
schedule()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// -----------------------------------------------------------------------------------------
|
|
75
|
+
// Binding
|
|
76
|
+
// -----------------------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
fun attachTo(player: Player?) {
|
|
79
|
+
if (this.player !== player) {
|
|
80
|
+
this.player?.removeListener(this)
|
|
81
|
+
this.player = player
|
|
82
|
+
player?.addListener(this)
|
|
83
|
+
}
|
|
84
|
+
// Re-resolved unconditionally: the same player switches between text tracks, and this doubles
|
|
85
|
+
// as the first resolve, before `onTracksChanged` has had anything to say.
|
|
86
|
+
resolveScript()
|
|
87
|
+
schedule()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fun detach() {
|
|
91
|
+
player?.removeListener(this)
|
|
92
|
+
player = null
|
|
93
|
+
script = null
|
|
94
|
+
layouts = HashMap()
|
|
95
|
+
setActive(emptyList())
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
override fun onTracksChanged(tracks: Tracks) {
|
|
99
|
+
resolveScript()
|
|
100
|
+
schedule()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private fun resolveScript() {
|
|
104
|
+
val next = AssSignStore.get(selectedTextTrackId())
|
|
105
|
+
if (next === script) return
|
|
106
|
+
script = next
|
|
107
|
+
layouts = HashMap()
|
|
108
|
+
setActive(emptyList())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `Format.id` of the selected text track, which `SubtitleSource.toSubtitleConfiguration` sets to
|
|
113
|
+
* `expo-subtitle:<language>:<uri>` — the same value the parser filed its signs under.
|
|
114
|
+
*/
|
|
115
|
+
private fun selectedTextTrackId(): String? {
|
|
116
|
+
val groups = player?.currentTracks?.groups ?: return null
|
|
117
|
+
for (group in groups) {
|
|
118
|
+
if (group.type != C.TRACK_TYPE_TEXT) continue
|
|
119
|
+
for (index in 0 until group.length) {
|
|
120
|
+
if (group.isTrackSelected(index)) return group.getTrackFormat(index).id
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// -----------------------------------------------------------------------------------------
|
|
127
|
+
// The clock
|
|
128
|
+
// -----------------------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
override fun onAttachedToWindow() {
|
|
131
|
+
super.onAttachedToWindow()
|
|
132
|
+
schedule()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
override fun onDetachedFromWindow() {
|
|
136
|
+
super.onDetachedFromWindow()
|
|
137
|
+
Choreographer.getInstance().removeFrameCallback(onFrame)
|
|
138
|
+
frameScheduled = false
|
|
139
|
+
}
|
|
57
140
|
|
|
58
|
-
|
|
59
|
-
|
|
141
|
+
private fun schedule() {
|
|
142
|
+
// No script means nothing to draw and no reason to hold a frame callback open. Paused playback
|
|
143
|
+
// still ticks, because a seek while paused has to move the signs with it.
|
|
144
|
+
if (frameScheduled || script == null || !isAttachedToWindow) return
|
|
145
|
+
frameScheduled = true
|
|
146
|
+
Choreographer.getInstance().postFrameCallback(onFrame)
|
|
60
147
|
}
|
|
61
148
|
|
|
62
|
-
|
|
63
|
-
|
|
149
|
+
private fun tick() {
|
|
150
|
+
val script = this.script ?: return
|
|
151
|
+
val player = this.player ?: return
|
|
152
|
+
setActive(script.signsAt(player.contentPosition * 1000L))
|
|
64
153
|
}
|
|
65
154
|
|
|
66
|
-
private fun
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// and hands out the same instance for every segment the event spans.
|
|
70
|
-
if (next.size == signs.size && next.indices.all { next[it] === signs[it] }) return
|
|
71
|
-
signs = next
|
|
155
|
+
private fun setActive(next: List<AssSign>) {
|
|
156
|
+
if (next.size == active.size && next.indices.all { next[it] === active[it] }) return
|
|
157
|
+
active = next
|
|
72
158
|
rebuild()
|
|
73
159
|
}
|
|
74
160
|
|
|
75
161
|
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
76
162
|
super.onSizeChanged(w, h, oldw, oldh)
|
|
77
163
|
// Every measurement below is in view pixels, so all of it is stale after a resize — which
|
|
78
|
-
// happens on
|
|
164
|
+
// happens on rotation and on entering full screen.
|
|
165
|
+
layouts = HashMap()
|
|
79
166
|
rebuild()
|
|
80
167
|
}
|
|
81
168
|
|
|
82
|
-
/**
|
|
83
|
-
* Lays the signs out once per change rather than once per frame.
|
|
84
|
-
*
|
|
85
|
-
* Frame-by-frame typesetting changes the on-screen set every 40 ms, and `onDraw` can run more
|
|
86
|
-
* often than that for reasons of the view system's own. Text layout is the expensive half of
|
|
87
|
-
* drawing, and none of it depends on anything that varies between frames.
|
|
88
|
-
*/
|
|
169
|
+
/** Lays out only what is new on screen, and keeps the rest as it was. */
|
|
89
170
|
private fun rebuild() {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
171
|
+
if (width == 0 || height == 0) {
|
|
172
|
+
prepared = emptyList()
|
|
173
|
+
invalidate()
|
|
174
|
+
return
|
|
175
|
+
}
|
|
93
176
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
setSigns(player?.currentCues?.cues ?: emptyList())
|
|
177
|
+
val next = HashMap<AssSign, PreparedSign>(active.size)
|
|
178
|
+
prepared = active.mapNotNull { sign ->
|
|
179
|
+
(layouts[sign] ?: prepare(sign))?.also { next[sign] = it }
|
|
180
|
+
}
|
|
181
|
+
layouts = next
|
|
182
|
+
invalidate()
|
|
101
183
|
}
|
|
102
184
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
signs = emptyList()
|
|
107
|
-
rebuild()
|
|
108
|
-
}
|
|
185
|
+
// -----------------------------------------------------------------------------------------
|
|
186
|
+
// Drawing
|
|
187
|
+
// -----------------------------------------------------------------------------------------
|
|
109
188
|
|
|
110
189
|
override fun onDraw(canvas: Canvas) {
|
|
111
190
|
for (sign in prepared) {
|
|
112
|
-
|
|
191
|
+
canvas.save()
|
|
192
|
+
canvas.concat(sign.matrix)
|
|
193
|
+
canvas.translate(sign.left, sign.top)
|
|
194
|
+
sign.stroke?.draw(canvas)
|
|
195
|
+
sign.fill.draw(canvas)
|
|
196
|
+
canvas.restore()
|
|
113
197
|
}
|
|
114
198
|
}
|
|
115
199
|
|
|
116
|
-
|
|
117
|
-
canvas.save()
|
|
118
|
-
canvas.concat(sign.matrix)
|
|
119
|
-
canvas.translate(sign.left, sign.top)
|
|
120
|
-
sign.stroke?.draw(canvas)
|
|
121
|
-
sign.fill.draw(canvas)
|
|
122
|
-
canvas.restore()
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Everything about one sign that does not change between frames. */
|
|
200
|
+
/** Everything about one sign that does not change while it is on screen. */
|
|
126
201
|
private class PreparedSign(
|
|
127
202
|
val matrix: Matrix,
|
|
128
203
|
val left: Float,
|
|
@@ -141,17 +216,24 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
|
|
|
141
216
|
if (alpha == 0) return null
|
|
142
217
|
|
|
143
218
|
// A fresh paint per sign: StaticLayout keeps the reference it was built with, so a shared
|
|
144
|
-
// mutable one would repaint every sign already laid out
|
|
219
|
+
// mutable one would repaint every sign already laid out.
|
|
145
220
|
val fillPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
|
|
146
221
|
fillPaint.applySign(sign, scaleToViewY)
|
|
147
|
-
fillPaint.color =
|
|
222
|
+
fillPaint.color = sign.colour.withAlpha(alpha)
|
|
148
223
|
|
|
149
224
|
// Wrapping is the script's job, not ours: a sign is placed by hand and its line breaks are
|
|
150
225
|
// written into the text as \N. A width of "as wide as it wants" reproduces that.
|
|
151
226
|
val textWidth = Layout.getDesiredWidth(sign.text, fillPaint).toInt() + 1
|
|
227
|
+
// A multi-line sign wraps the way its own alignment says, not always centred: a left-anchored
|
|
228
|
+
// placard reads wrong with its second line pulled to the middle.
|
|
229
|
+
val blockAlignment = when ((sign.alignment - 1) % 3) {
|
|
230
|
+
0 -> Layout.Alignment.ALIGN_NORMAL
|
|
231
|
+
2 -> Layout.Alignment.ALIGN_OPPOSITE
|
|
232
|
+
else -> Layout.Alignment.ALIGN_CENTER
|
|
233
|
+
}
|
|
152
234
|
val layout = StaticLayout.Builder
|
|
153
235
|
.obtain(sign.text, 0, sign.text.length, fillPaint, textWidth)
|
|
154
|
-
.setAlignment(
|
|
236
|
+
.setAlignment(blockAlignment)
|
|
155
237
|
.setIncludePad(false)
|
|
156
238
|
.build()
|
|
157
239
|
|
|
@@ -170,23 +252,31 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
|
|
|
170
252
|
}
|
|
171
253
|
|
|
172
254
|
// \org moves the centre of rotation away from the text, which is how a sign stays glued to a
|
|
173
|
-
// placard
|
|
174
|
-
//
|
|
255
|
+
// placard pivoting off-screen. Without it every rotation would spin about the text's own middle
|
|
256
|
+
// and drift off the artwork.
|
|
175
257
|
val pivotX = sign.pivotX * scaleToViewX
|
|
176
258
|
val pivotY = sign.pivotY * scaleToViewY
|
|
177
259
|
|
|
178
|
-
|
|
260
|
+
// Order matters, and it is not the order the calls read in. Android's `post*` methods
|
|
261
|
+
// pre-multiply — `M.postRotate(r)` maps a point as `R * (M * p)` — so the first call written is
|
|
262
|
+
// the first one applied to the glyph. ASS scales the glyph and *then* turns it, and with an
|
|
263
|
+
// anisotropic scale (this episode carries fscx 125.86 against fscy 120.19) the two do not
|
|
264
|
+
// commute, so getting it backwards changes the shape rather than just the arithmetic.
|
|
265
|
+
val matrix = Matrix()
|
|
266
|
+
matrix.postScale(sign.scaleX, sign.scaleY, pivotX, pivotY)
|
|
179
267
|
if (sign.rotationX != 0f || sign.rotationY != 0f) {
|
|
268
|
+
val cameraMatrix = Matrix()
|
|
180
269
|
camera.save()
|
|
181
270
|
camera.rotateX(sign.rotationX)
|
|
182
271
|
camera.rotateY(sign.rotationY)
|
|
183
|
-
|
|
272
|
+
// `getMatrix` overwrites rather than concatenating, so it needs a matrix of its own.
|
|
273
|
+
camera.getMatrix(cameraMatrix)
|
|
184
274
|
camera.restore()
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
275
|
+
// The camera transforms about the origin; walk the pivot there and back.
|
|
276
|
+
cameraMatrix.preTranslate(-pivotX, -pivotY)
|
|
277
|
+
cameraMatrix.postTranslate(pivotX, pivotY)
|
|
278
|
+
matrix.postConcat(cameraMatrix)
|
|
188
279
|
}
|
|
189
|
-
matrix.postScale(sign.scaleX, sign.scaleY, pivotX, pivotY)
|
|
190
280
|
matrix.postRotate(sign.rotationZ, pivotX, pivotY)
|
|
191
281
|
|
|
192
282
|
val stroke = if (sign.outlineWidth <= 0f) null else {
|
|
@@ -194,17 +284,17 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
|
|
|
194
284
|
strokePaint.style = Paint.Style.STROKE
|
|
195
285
|
strokePaint.applySign(sign, scaleToViewY)
|
|
196
286
|
strokePaint.strokeWidth = sign.outlineWidth * scaleToViewY * 2f
|
|
197
|
-
strokePaint.color =
|
|
287
|
+
strokePaint.color = sign.outlineColour.withAlpha(alpha)
|
|
198
288
|
// The stroke is centred on the glyph edge, so half of it lands inside the letter. Drawing the
|
|
199
|
-
// fill
|
|
289
|
+
// fill over it leaves the outline the width the script asked for.
|
|
200
290
|
StaticLayout.Builder
|
|
201
291
|
.obtain(sign.text, 0, sign.text.length, strokePaint, textWidth)
|
|
202
|
-
.setAlignment(
|
|
292
|
+
.setAlignment(blockAlignment)
|
|
203
293
|
.setIncludePad(false)
|
|
204
294
|
.build()
|
|
205
295
|
}
|
|
206
296
|
|
|
207
|
-
return PreparedSign(
|
|
297
|
+
return PreparedSign(matrix, left, top, layout, stroke)
|
|
208
298
|
}
|
|
209
299
|
|
|
210
300
|
private fun TextPaint.applySign(sign: AssSign, scaleToViewY: Float) {
|
|
@@ -221,22 +311,15 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
|
|
|
221
311
|
isUnderlineText = sign.underline
|
|
222
312
|
}
|
|
223
313
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
314
|
+
/** ASS colours carry no alpha byte; opacity travels separately. */
|
|
315
|
+
private fun Int.withAlpha(alpha: Int): Int {
|
|
316
|
+
val opaque = this or OPAQUE
|
|
317
|
+
return Color.argb(alpha, Color.red(opaque), Color.green(opaque), Color.blue(opaque))
|
|
318
|
+
}
|
|
227
319
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return text.getSpans(0, text.length, AssSignSpan::class.java).firstOrNull()?.sign
|
|
231
|
-
}
|
|
320
|
+
companion object {
|
|
321
|
+
private const val OPAQUE = 0xFF000000.toInt()
|
|
232
322
|
|
|
233
|
-
/**
|
|
234
|
-
* Inserts the overlay in front of `PlayerView`'s subtitle view, or returns the one already
|
|
235
|
-
* there.
|
|
236
|
-
*
|
|
237
|
-
* Returns null when the player view has no subtitle view — a configuration this package does
|
|
238
|
-
* not produce, but `PlayerView` allows.
|
|
239
|
-
*/
|
|
240
323
|
/** Removes the overlay from [playerView], dropping its listener registration with it. */
|
|
241
324
|
fun remove(playerView: PlayerView) {
|
|
242
325
|
val parent = playerView.subtitleView?.parent as? ViewGroup ?: return
|
|
@@ -248,6 +331,13 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
|
|
|
248
331
|
}
|
|
249
332
|
}
|
|
250
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Inserts the overlay in front of `PlayerView`'s subtitle view, or returns the one already
|
|
336
|
+
* there.
|
|
337
|
+
*
|
|
338
|
+
* Returns null when the player view has no subtitle view — a configuration this package does not
|
|
339
|
+
* produce, but `PlayerView` allows.
|
|
340
|
+
*/
|
|
251
341
|
fun install(playerView: PlayerView): AssSignOverlay? {
|
|
252
342
|
val subtitleView: SubtitleView = playerView.subtitleView ?: return null
|
|
253
343
|
val parent = subtitleView.parent as? ViewGroup ?: return null
|
|
@@ -257,12 +347,13 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
|
|
|
257
347
|
}
|
|
258
348
|
|
|
259
349
|
val overlay = AssSignOverlay(playerView.context)
|
|
260
|
-
// Directly in front of the captions: a sign belongs to the picture, and the dialogue box
|
|
261
|
-
//
|
|
262
|
-
parent.addView(
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
350
|
+
// Directly in front of the captions: a sign belongs to the picture, and the dialogue box must
|
|
351
|
+
// not be able to cover it.
|
|
352
|
+
parent.addView(
|
|
353
|
+
overlay,
|
|
354
|
+
parent.indexOfChild(subtitleView) + 1,
|
|
355
|
+
ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT),
|
|
356
|
+
)
|
|
266
357
|
return overlay
|
|
267
358
|
}
|
|
268
359
|
}
|
|
@@ -37,8 +37,15 @@ internal object AssSignParser {
|
|
|
37
37
|
private const val DEFAULT_PLAY_RES_X = 384f
|
|
38
38
|
private const val DEFAULT_PLAY_RES_Y = 288f
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Matches one override block, `{…}`. The contents are scanned separately.
|
|
42
|
+
*
|
|
43
|
+
* Both braces are escaped. A bare closing brace is legal in `java.util.regex` and a syntax error
|
|
44
|
+
* in ICU, which is the engine Android actually uses — so the unescaped form compiles and passes
|
|
45
|
+
* on a JVM test run, then throws `PatternSyntaxException` from a static initialiser on device and
|
|
46
|
+
* takes the playback thread down with it.
|
|
47
|
+
*/
|
|
48
|
+
private val BRACES = Regex("""\{([^}]*)\}""")
|
|
42
49
|
|
|
43
50
|
/**
|
|
44
51
|
* Splits a script into the part this package draws and the part Media3 draws.
|
|
@@ -225,13 +232,21 @@ internal object AssSignParser {
|
|
|
225
232
|
)
|
|
226
233
|
}
|
|
227
234
|
|
|
228
|
-
/**
|
|
235
|
+
/**
|
|
236
|
+
* `H:MM:SS.cc` or `MM:SS.cc`, centiseconds.
|
|
237
|
+
*
|
|
238
|
+
* The hour is optional because Media3's own `SSA_TIMECODE_PATTERN` makes it optional. Requiring it
|
|
239
|
+
* was worse than a parse failure: an event this rejected was never classified, so it stayed in the
|
|
240
|
+
* copy handed to Media3 — whose `SsaStyle.Overrides` reads `\pos` perfectly well and would draw
|
|
241
|
+
* the sign flat and unrotated, which is the exact failure this whole subsystem exists to remove,
|
|
242
|
+
* with nothing in the logs to say it had happened.
|
|
243
|
+
*/
|
|
229
244
|
private fun parseTimeUs(value: String): Long? {
|
|
230
245
|
val parts = value.split(':')
|
|
231
|
-
if (parts.size
|
|
232
|
-
val h = parts[0].trim().toLongOrNull() ?: return null
|
|
233
|
-
val m = parts[
|
|
234
|
-
val s = parts[
|
|
246
|
+
if (parts.size !in 2..3) return null
|
|
247
|
+
val h = if (parts.size == 3) parts[0].trim().toLongOrNull() ?: return null else 0L
|
|
248
|
+
val m = parts[parts.size - 2].trim().toLongOrNull() ?: return null
|
|
249
|
+
val s = parts[parts.size - 1].trim().toDoubleOrNull() ?: return null
|
|
235
250
|
return ((h * 3600 + m * 60) * 1_000_000L) + (s * 1_000_000.0).toLong()
|
|
236
251
|
}
|
|
237
252
|
|
|
@@ -493,11 +508,39 @@ internal data class AssSign(
|
|
|
493
508
|
val playResY: Float,
|
|
494
509
|
)
|
|
495
510
|
|
|
496
|
-
internal
|
|
511
|
+
internal class AssScript(
|
|
497
512
|
val playResX: Float,
|
|
498
513
|
val playResY: Float,
|
|
499
514
|
/** Typeset events, drawn by [AssSignOverlay]. */
|
|
500
515
|
val signs: List<AssSign>,
|
|
501
516
|
/** The script with every sign, drawing and invisible event cut out, for Media3's own parser. */
|
|
502
517
|
val dialogueOnly: String,
|
|
503
|
-
)
|
|
518
|
+
) {
|
|
519
|
+
/**
|
|
520
|
+
* Signs bucketed by the whole second they are on screen during, so the overlay can answer "what is
|
|
521
|
+
* showing now" once per frame without walking twelve thousand events.
|
|
522
|
+
*
|
|
523
|
+
* A bucket per second rather than a sorted sweep because playback is not monotonic — every seek
|
|
524
|
+
* would otherwise need a rebuild — and because frame-by-frame typesetting is almost entirely
|
|
525
|
+
* events shorter than one frame, so nearly every sign lands in exactly one bucket.
|
|
526
|
+
*/
|
|
527
|
+
val signsBySecond: Map<Long, List<AssSign>> by lazy {
|
|
528
|
+
val buckets = HashMap<Long, MutableList<AssSign>>()
|
|
529
|
+
for (sign in signs) {
|
|
530
|
+
val first = sign.startUs / 1_000_000L
|
|
531
|
+
// `endUs` is exclusive, so an event ending exactly on a second boundary does not reach into
|
|
532
|
+
// the next bucket.
|
|
533
|
+
val last = (sign.endUs - 1).coerceAtLeast(sign.startUs) / 1_000_000L
|
|
534
|
+
for (second in first..last) {
|
|
535
|
+
buckets.getOrPut(second) { ArrayList(4) }.add(sign)
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
buckets
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** The signs on screen at [positionUs], in file order. */
|
|
542
|
+
fun signsAt(positionUs: Long): List<AssSign> {
|
|
543
|
+
val bucket = signsBySecond[positionUs / 1_000_000L] ?: return emptyList()
|
|
544
|
+
return bucket.filter { positionUs >= it.startUs && positionUs < it.endUs }
|
|
545
|
+
}
|
|
546
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
package expo.modules.video.utils
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hands parsed ASS typesetting from the subtitle parser to [AssSignOverlay].
|
|
5
|
+
*
|
|
6
|
+
* ### Why not the cue stream
|
|
7
|
+
*
|
|
8
|
+
* The obvious route — wrap each sign in a span on a cue and let Media3's timing carry it — does not
|
|
9
|
+
* survive the trip. `SubtitleExtractor` writes every `CuesWithTiming` into the sample queue through
|
|
10
|
+
* `CueEncoder`, which calls `Cue.toSerializableBundle()` and then `Parcel.marshall()`. Spans are
|
|
11
|
+
* only preserved if they are one of the three Media3 declares in `CustomSpanBundler` or a framework
|
|
12
|
+
* `ParcelableSpan`; anything else is dropped without a word. A custom span arrives on the other side
|
|
13
|
+
* as plain text with nothing attached, so the overlay saw no signs at all — no crash, no log, just
|
|
14
|
+
* an empty screen where the typesetting should be.
|
|
15
|
+
*
|
|
16
|
+
* Two further reasons not to go back to it even if a span could be smuggled through. A typeset
|
|
17
|
+
* episode holds 12,616 sign events, which would put ~26,000 boundaries into
|
|
18
|
+
* [StackingSubtitleParser]'s timeline pass — an O(n²) sweep over every entry at every boundary — and
|
|
19
|
+
* then write ~26,000 samples into the queue. Keeping signs out of the cue stream leaves the
|
|
20
|
+
* dialogue-only file at a few hundred events, which is the size that pass was written for.
|
|
21
|
+
*
|
|
22
|
+
* ### The key
|
|
23
|
+
*
|
|
24
|
+
* `Format.id`, which `SubtitleSource.toSubtitleConfiguration` sets to
|
|
25
|
+
* `expo-subtitle:<language>:<uri>` — stable, unique per track, and readable from both ends: the
|
|
26
|
+
* parser gets it from the `Format` its factory was given, and the overlay reads it off the selected
|
|
27
|
+
* text track. Without a match the overlay draws nothing rather than guessing, because guessing means
|
|
28
|
+
* drawing one language's typesetting over another's.
|
|
29
|
+
*/
|
|
30
|
+
internal object AssSignStore {
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Enough for the track being watched plus a few the viewer switched away from. Each entry holds
|
|
34
|
+
* every sign in an episode — 12,616 in the file this was built against — so this is not a cache to
|
|
35
|
+
* let grow.
|
|
36
|
+
*/
|
|
37
|
+
private const val MAX_TRACKS = 4
|
|
38
|
+
|
|
39
|
+
private val scripts = object : LinkedHashMap<String, AssScript>(MAX_TRACKS, 0.75f, true) {
|
|
40
|
+
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, AssScript>): Boolean =
|
|
41
|
+
size > MAX_TRACKS
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Called from the extractor's loading thread. */
|
|
45
|
+
@Synchronized
|
|
46
|
+
fun put(trackId: String, script: AssScript) {
|
|
47
|
+
scripts[trackId] = script
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Called from the main thread, once per track change. */
|
|
51
|
+
@Synchronized
|
|
52
|
+
fun get(trackId: String?): AssScript? = trackId?.let { scripts[it] }
|
|
53
|
+
|
|
54
|
+
@Synchronized
|
|
55
|
+
fun clear() {
|
|
56
|
+
scripts.clear()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -191,9 +191,6 @@ class RoundedSubtitleBackground private constructor(private val backgroundView:
|
|
|
191
191
|
*/
|
|
192
192
|
private fun toBackgroundCue(cue: Cue): Cue? {
|
|
193
193
|
val text = cue.text ?: return null // Bitmap cues carry no text to put a box behind.
|
|
194
|
-
// A sign's cue is a zero-width placeholder standing in for text drawn by `AssSignOverlay`.
|
|
195
|
-
// There is no glyph here to sit behind, and the box would be the only thing on screen.
|
|
196
|
-
if (text is Spanned && text.getSpans(0, text.length, AssSignSpan::class.java).isNotEmpty()) return null
|
|
197
194
|
val stripped = if (keepMetricSpans && text is Spanned) {
|
|
198
195
|
SpannableString(text).also { spannable ->
|
|
199
196
|
for (span in spannable.getSpans(0, spannable.length, Any::class.java)) {
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
package expo.modules.video.utils
|
|
2
2
|
|
|
3
|
-
import android.text.SpannableString
|
|
4
3
|
import android.text.SpannableStringBuilder
|
|
5
|
-
import android.text.Spanned
|
|
6
4
|
import androidx.annotation.OptIn
|
|
7
5
|
import androidx.media3.common.C
|
|
8
6
|
import androidx.media3.common.Format
|
|
9
7
|
import androidx.media3.common.MimeTypes
|
|
10
8
|
import androidx.media3.common.text.Cue
|
|
11
9
|
import androidx.media3.common.util.Consumer
|
|
10
|
+
import androidx.media3.common.util.Log
|
|
12
11
|
import androidx.media3.common.util.UnstableApi
|
|
13
12
|
import androidx.media3.extractor.text.CuesWithTiming
|
|
14
13
|
import androidx.media3.extractor.text.DefaultSubtitleParserFactory
|
|
@@ -76,24 +75,14 @@ open class StackingSubtitleParser(
|
|
|
76
75
|
) : SubtitleParser {
|
|
77
76
|
|
|
78
77
|
/**
|
|
79
|
-
* A chance for a subclass to
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* Default is to change nothing.
|
|
78
|
+
* A chance for a subclass to hand the delegate something other than the bytes it was given —
|
|
79
|
+
* [StackingSsaParser] removes the typeset events it draws itself. Default is to change nothing.
|
|
83
80
|
*/
|
|
84
81
|
protected open fun split(data: ByteArray, offset: Int, length: Int): Split =
|
|
85
|
-
Split(data, offset, length
|
|
86
|
-
|
|
87
|
-
/** [split]'s result: what the delegate parses, and what the subclass contributes. */
|
|
88
|
-
protected class Split(
|
|
89
|
-
val data: ByteArray,
|
|
90
|
-
val offset: Int,
|
|
91
|
-
val length: Int,
|
|
92
|
-
val ownCues: List<PreparedCue>,
|
|
93
|
-
)
|
|
82
|
+
Split(data, offset, length)
|
|
94
83
|
|
|
95
|
-
/**
|
|
96
|
-
protected class
|
|
84
|
+
/** [split]'s result: the bytes the delegate should parse. */
|
|
85
|
+
protected class Split(val data: ByteArray, val offset: Int, val length: Int)
|
|
97
86
|
|
|
98
87
|
// Every emitted segment is a complete description of what should be on screen for its interval,
|
|
99
88
|
// so segments replace each other rather than being merged by the renderer.
|
|
@@ -112,9 +101,6 @@ open class StackingSubtitleParser(
|
|
|
112
101
|
) {
|
|
113
102
|
val entries = mutableListOf<Entry>()
|
|
114
103
|
val split = split(data, offset, length)
|
|
115
|
-
for (prepared in split.ownCues) {
|
|
116
|
-
entries.add(Entry(prepared.cue, prepared.startUs, prepared.endUs, entries.size))
|
|
117
|
-
}
|
|
118
104
|
// Always ask the delegate for everything; `outputOptions` is applied to the merged result below,
|
|
119
105
|
// because a segment can span cues that fall on both sides of the requested start time.
|
|
120
106
|
delegate.parse(split.data, split.offset, split.length, SubtitleParser.OutputOptions.allCues()) { cuesWithTiming ->
|
|
@@ -319,48 +305,58 @@ class StackingSubripParser : StackingSubtitleParser(SubripParser())
|
|
|
319
305
|
/**
|
|
320
306
|
* ASS and SSA.
|
|
321
307
|
*
|
|
322
|
-
* Splits the script
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
308
|
+
* Splits the script before anything else runs. [AssSignParser] takes the typeset events — anything
|
|
309
|
+
* the author placed — and the delegate gets a copy with those lines removed, so the two renderers
|
|
310
|
+
* can never draw the same event. The signs go to [AssSignStore] for [AssSignOverlay] to pick up;
|
|
311
|
+
* they deliberately do **not** ride the cue stream, because a custom span does not survive
|
|
312
|
+
* `CueEncoder`'s trip through `Parcel` and because twelve thousand of them would swamp the timeline
|
|
313
|
+
* pass above. See [AssSignStore] for the whole reasoning.
|
|
327
314
|
*
|
|
328
|
-
* Falls back to plain delegation when the script yields no signs, which is every `.ass` that is
|
|
329
|
-
*
|
|
315
|
+
* Falls back to plain delegation when the script yields no signs, which is every `.ass` that is only
|
|
316
|
+
* dialogue, and when anything at all goes wrong.
|
|
330
317
|
*
|
|
331
318
|
* The delegate is built by the caller rather than constructed here because an SSA track muxed into
|
|
332
319
|
* a container carries its `[V4+ Styles]` header in the format's initialization data, and only
|
|
333
320
|
* `DefaultSubtitleParserFactory` knows to pass it on. A parser built without it resolves no styles.
|
|
334
321
|
*/
|
|
335
322
|
@OptIn(UnstableApi::class)
|
|
336
|
-
class StackingSsaParser(
|
|
337
|
-
|
|
323
|
+
class StackingSsaParser(
|
|
324
|
+
delegate: SubtitleParser,
|
|
325
|
+
/**
|
|
326
|
+
* `Format.id` of the track being parsed, which is how the overlay finds these signs again. Null
|
|
327
|
+
* means no lookup key, so the signs are parsed out of the delegate's copy but never drawn — the
|
|
328
|
+
* captions stay correct, the typesetting is simply absent.
|
|
329
|
+
*/
|
|
330
|
+
private val trackId: String?,
|
|
331
|
+
) : StackingSubtitleParser(delegate, SsaCueNormalizer::normalize) {
|
|
338
332
|
|
|
339
333
|
override fun split(data: ByteArray, offset: Int, length: Int): Split {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
return
|
|
334
|
+
// Nothing in here is worth a crash, and the whole body is inside the guard rather than just the
|
|
335
|
+
// parse. This runs on the extractor's loading thread, where a thrown exception is not a failed
|
|
336
|
+
// subtitle — it takes playback down with it, which is how a stray `}` in a regex turned into a
|
|
337
|
+
// dead player on an episode that had been playing fine.
|
|
338
|
+
//
|
|
339
|
+
// A subtitle file is untrusted input from a scraper. The honest failure for one this cannot
|
|
340
|
+
// read is captions drawn Media3's own way, flat, rather than no video at all.
|
|
341
|
+
return runCatching {
|
|
342
|
+
if (trackId == null) return@runCatching Split(data, offset, length)
|
|
343
|
+
|
|
344
|
+
val script = AssSignParser.parse(String(data, offset, length, Charsets.UTF_8))
|
|
345
|
+
if (script.signs.isEmpty()) {
|
|
346
|
+
Split(data, offset, length)
|
|
347
|
+
} else {
|
|
348
|
+
AssSignStore.put(trackId, script)
|
|
349
|
+
val dialogue = script.dialogueOnly.toByteArray(Charsets.UTF_8)
|
|
350
|
+
Split(dialogue, 0, dialogue.size)
|
|
351
|
+
}
|
|
352
|
+
}.getOrElse { error ->
|
|
353
|
+
Log.w(TAG, "Sign rendering unavailable for this track; falling back to Media3", error)
|
|
354
|
+
Split(data, offset, length)
|
|
355
|
+
}
|
|
348
356
|
}
|
|
349
357
|
|
|
350
|
-
private
|
|
351
|
-
val
|
|
352
|
-
text.setSpan(AssSignSpan(sign), 0, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
353
|
-
|
|
354
|
-
// Positioned so the stacking pass leaves it alone and the span filter keeps it intact. The
|
|
355
|
-
// values are only a rough placement for the invisible placeholder; the overlay reads the real
|
|
356
|
-
// coordinates off the span.
|
|
357
|
-
val cue = Cue.Builder()
|
|
358
|
-
.setText(text)
|
|
359
|
-
.setPosition((sign.x / sign.playResX).coerceIn(0f, 1f))
|
|
360
|
-
.setLine((sign.y / sign.playResY).coerceIn(0f, 1f), Cue.LINE_TYPE_FRACTION)
|
|
361
|
-
.build()
|
|
362
|
-
|
|
363
|
-
return PreparedCue(cue, sign.startUs, sign.endUs)
|
|
358
|
+
private companion object {
|
|
359
|
+
const val TAG = "StackingSsaParser"
|
|
364
360
|
}
|
|
365
361
|
}
|
|
366
362
|
|
|
@@ -391,7 +387,7 @@ class ExpoVideoSubtitleParserFactory : SubtitleParser.Factory {
|
|
|
391
387
|
// reliably a sign — typeset to sit on the artwork, in colours chosen to match it — rather than
|
|
392
388
|
// dialogue that happens to carry a cue setting.
|
|
393
389
|
format.isSsa() -> FilteringSubtitleParser(
|
|
394
|
-
StackingSsaParser(defaultFactory.create(format)),
|
|
390
|
+
StackingSsaParser(defaultFactory.create(format), format.id),
|
|
395
391
|
keepAppearanceOfPositionedCues = true,
|
|
396
392
|
)
|
|
397
393
|
else -> FilteringSubtitleParser(defaultFactory.create(format))
|
package/package.json
CHANGED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
package expo.modules.video.utils
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Carries one parsed ASS sign through Media3's cue stream to [AssSignOverlay].
|
|
5
|
-
*
|
|
6
|
-
* The cue itself is a single zero-width character, so `SubtitleView` lays it out and draws nothing
|
|
7
|
-
* while the overlay reads the sign off this span and draws it properly. Riding the cue stream is
|
|
8
|
-
* what buys the timing for free: Media3 already decides which cues are on screen at each moment,
|
|
9
|
-
* and a sign is on screen exactly as long as its cue is.
|
|
10
|
-
*
|
|
11
|
-
* A marker span rather than a field on `Cue` because `Cue` is final and carries no room for one —
|
|
12
|
-
* it has `shearDegrees` and nothing else transform-shaped, and `SubtitlePainter` ignores even that.
|
|
13
|
-
*/
|
|
14
|
-
internal class AssSignSpan(val sign: AssSign)
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* The cue text a sign rides on.
|
|
18
|
-
*
|
|
19
|
-
* A zero-width space still occupies a line of height in `SubtitleView`, which is harmless for a
|
|
20
|
-
* positioned cue and keeps the span anchored to something. Distinct from [HELD_SLOT_PLACEHOLDER]
|
|
21
|
-
* only in intent; both are invisible.
|
|
22
|
-
*/
|
|
23
|
-
internal const val SIGN_CUE_PLACEHOLDER = ""
|