expo-video-subtitle 0.7.5 → 0.8.0

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 CHANGED
@@ -7,6 +7,78 @@ 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.8.0
11
+
12
+ ### Added
13
+
14
+ - **ASS signs are drawn with their transforms on iOS too.** AVFoundation renders a sideloaded
15
+ WebVTT track itself, and WebVTT can express no rotation, no scale and no per-cue opacity;
16
+ `AVTextStyleRule` has no attribute for any of them either. So the same split the Android side
17
+ makes: `SubtitleConverter.splitAss` takes the typeset events out of the script before the WebVTT
18
+ is built, and `AssSignOverlayView` draws them in `AVPlayerViewController.contentOverlayView`,
19
+ sized to `videoBounds` and driven by a `CADisplayLink`.
20
+
21
+ Signs travel on the `AVPlayerItem` that owns them rather than through a store of their own. A
22
+ store would have to be keyed by something both ends can see, and the only identity a sideloaded
23
+ track keeps inside a composition is its language — so two players loading different videos in the
24
+ same language would overwrite each other, and the next episode would inherit the previous one's
25
+ typesetting. The item is the identity, and the overlay re-resolves whenever the item or the
26
+ selected language changes.
27
+
28
+ Read and honoured: `\pos`, `\move` (at its destination), `\org`, `\an` and the legacy `\a`,
29
+ `\frz`, `\fscx`, `\fscy`, `\fs`, `\c` / `\1c`, `\alpha` / `\1a`, `\b`, `\i`, `\u`, and the
30
+ style columns behind them.
31
+
32
+ ### Known limitations on iOS
33
+
34
+ - `\frx` and `\fry` are not applied. `CGAffineTransform` is two-dimensional, and the values are
35
+ almost always under 3° in practice; `\frz` is what carries the effect.
36
+ - Signs do not appear in Picture in Picture. The system renders that window from the video layer
37
+ alone, not from the view hierarchy `contentOverlayView` belongs to.
38
+ - `\t` animation, `\fad`, `\clip`, `\blur`, `\be`, `\fax` shear, vector drawings and karaoke are
39
+ dropped, as on Android. Fonts fall back to the system face.
40
+
41
+ ### Fixed
42
+
43
+ - **Non-finite numbers are refused.** `Double("nan")` and `Double("inf")` parse, and a subtitle is
44
+ untrusted scraper output; either one reaching a transform makes a sign silently fail to draw,
45
+ which looks exactly like a sign that was never parsed. Every number out of a tag or a style column
46
+ now goes through a finiteness check.
47
+
48
+ ### Changed
49
+
50
+ - The override-block scanner reads a leading digit as part of a tag name, so `\1c`, `\1a` and `\4a`
51
+ are recognised. The WebVTT path ignores them as before — 91 existing checks confirm its output is
52
+ unchanged — but the sign path needs them.
53
+ - The Swift harness is at 119 checks, up from 91.
54
+
55
+ ## 0.7.6
56
+
57
+ ### Fixed
58
+
59
+ - **Signs stopped appearing at all.** The id lookup between the parser and the overlay had never
60
+ matched. Media3 prefixes a merged track's id with the index of the source it came from, so the
61
+ parser is handed `expo-subtitle:id:https://…` while the overlay reads
62
+ `2:expo-subtitle:id:https://…` off the selected track. Every lookup missed, and the
63
+ "use the only script on hand" fallback quietly covered for it — which is why removing that
64
+ fallback in 0.7.5 took the whole feature with it, and why the fallback's own bug (one episode's
65
+ signs drawing over the next) had been the only visible symptom until then.
66
+
67
+ Both ends now normalize the id through the same function before touching the map. Five tests pin
68
+ it, using the exact strings a device produced, and were checked by removing the normalization and
69
+ watching them fail.
70
+
71
+ - **A track with no typesetting no longer logs once a second forever.** The parser files its result
72
+ even when there are no signs in it. Absence in the store used to mean two different things —
73
+ "still parsing" and "nothing to draw" — and the overlay kept asking for the second one.
74
+
75
+ ### Changed
76
+
77
+ - A missed lookup is now logged, throttled to once a second, naming both the id being asked for and
78
+ the ids on hand. Its absence is what let this bug hide behind a fallback for three releases: the
79
+ overlay asked, got nothing, and said nothing, so a wrong key looked exactly like a file that had
80
+ not finished parsing.
81
+
10
82
  ## 0.7.5
11
83
 
12
84
  ### Fixed
@@ -109,7 +109,18 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
109
109
  val trackId = selectedTextTrackId()
110
110
  val next = AssSignStore.get(trackId)
111
111
  scriptTrackId = trackId
112
- if (next === script) return
112
+ if (next === script) {
113
+ // Reported, not swallowed. A lookup that quietly finds nothing is how this subsystem has
114
+ // failed three times running: the overlay asks, gets nothing, and says nothing, so the log
115
+ // looks identical whether the file is still parsing or the key is simply wrong. Throttled to
116
+ // once a second because the poll runs four times that.
117
+ val now = SystemClock.uptimeMillis()
118
+ if (now - lastMissLogMs >= MISS_LOG_INTERVAL_MS) {
119
+ lastMissLogMs = now
120
+ Log.w(TAG, "no ASS signs for track $trackId; parsed so far: ${AssSignStore.keys()}")
121
+ }
122
+ return
123
+ }
113
124
  // The one line worth keeping. Every failure of this subsystem has been silent — a span dropped
114
125
  // by Parcel, a timestamp rejected, a loop that never started — and each time this line would
115
126
  // have named the stage that broke.
@@ -169,6 +180,7 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
169
180
 
170
181
 
171
182
  private var lastLookupMs = 0L
183
+ private var lastMissLogMs = 0L
172
184
 
173
185
  private fun tick() {
174
186
  val player = this.player ?: return
@@ -367,6 +379,9 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
367
379
 
368
380
  /** How often to ask the store for a script that has not arrived yet. */
369
381
  private const val LOOKUP_INTERVAL_MS = 250L
382
+
383
+ /** How often to say so when the answer is still nothing. */
384
+ private const val MISS_LOG_INTERVAL_MS = 1000L
370
385
  private const val OPAQUE = 0xFF000000.toInt()
371
386
 
372
387
  /** Removes the overlay from [playerView], dropping its listener registration with it. */
@@ -41,10 +41,27 @@ internal object AssSignStore {
41
41
  size > MAX_TRACKS
42
42
  }
43
43
 
44
+ /**
45
+ * A merged track's id carries the index of the source it came from: Media3 hands the overlay
46
+ * `2:expo-subtitle:id:https://…` for the track the parser was given as `expo-subtitle:id:https://…`.
47
+ * Both ends key on the same string only if that prefix is taken off first.
48
+ *
49
+ * Measured on a device, and it is what made every id lookup miss while a fallback quietly covered
50
+ * for it. Stripped repeatedly, since sources can nest, and safe for these ids because
51
+ * `SubtitleSource` always begins them with `expo-subtitle:`, never a digit.
52
+ */
53
+ private val MERGED_SOURCE_PREFIX = Regex("""^\d+:""")
54
+
55
+ fun normalizeTrackId(trackId: String): String {
56
+ var id = trackId
57
+ while (MERGED_SOURCE_PREFIX.containsMatchIn(id)) id = id.replaceFirst(MERGED_SOURCE_PREFIX, "")
58
+ return id
59
+ }
60
+
44
61
  /** Called from the extractor's loading thread. */
45
62
  @Synchronized
46
63
  fun put(trackId: String, script: AssScript) {
47
- scripts[trackId] = script
64
+ scripts[normalizeTrackId(trackId)] = script
48
65
  }
49
66
 
50
67
  /**
@@ -61,7 +78,11 @@ internal object AssSignStore {
61
78
  * script takes about twelve seconds, and the overlay polls until it has.
62
79
  */
63
80
  @Synchronized
64
- fun get(trackId: String?): AssScript? = trackId?.let { scripts[it] }
81
+ fun get(trackId: String?): AssScript? = trackId?.let { scripts[normalizeTrackId(it)] }
82
+
83
+ /** The keys on hand, for telling a "not parsed yet" miss from a "wrong key" miss. */
84
+ @Synchronized
85
+ fun keys(): List<String> = scripts.keys.toList()
65
86
 
66
87
  @Synchronized
67
88
  fun clear() {
@@ -342,10 +342,13 @@ class StackingSsaParser(
342
342
  if (trackId == null) return@runCatching Split(data, offset, length)
343
343
 
344
344
  val script = AssSignParser.parse(String(data, offset, length, Charsets.UTF_8))
345
+ // Filed even with no signs in it. The overlay cannot tell "still parsing" from "nothing to
346
+ // draw" by absence alone, and would go on asking — and logging — for the whole episode.
347
+ AssSignStore.put(trackId, script)
348
+
345
349
  if (script.signs.isEmpty()) {
346
350
  Split(data, offset, length)
347
351
  } else {
348
- AssSignStore.put(trackId, script)
349
352
  val dialogue = script.dialogueOnly.toByteArray(Charsets.UTF_8)
350
353
  Split(dialogue, 0, dialogue.size)
351
354
  }
@@ -0,0 +1,264 @@
1
+ import Foundation
2
+
3
+ /// One typeset event, in script coordinates. `AssSignOverlayView` maps it onto the video rect.
4
+ ///
5
+ /// The Android side has the same shape, field for field, and for the same reason: this is what a
6
+ /// renderer needs to put a sign on a placard, and AVFoundation can carry almost none of it.
7
+ struct AssSign: Equatable {
8
+ let startMs: Int
9
+ let endMs: Int
10
+ let text: String
11
+
12
+ let x: Double
13
+ let y: Double
14
+ let alignment: Int
15
+
16
+ /// Clockwise, having already been flipped from ASS's counter-clockwise measure.
17
+ let rotationZ: Double
18
+ let rotationX: Double
19
+ let rotationY: Double
20
+ let pivotX: Double
21
+ let pivotY: Double
22
+
23
+ let scaleX: Double
24
+ let scaleY: Double
25
+ let fontSize: Double
26
+ let colour: Int
27
+ let alpha: Double
28
+ let outlineColour: Int
29
+ let outlineWidth: Double
30
+ let bold: Bool
31
+ let italic: Bool
32
+ let underline: Bool
33
+
34
+ /// The coordinate space the values above are expressed in.
35
+ let playResX: Double
36
+ let playResY: Double
37
+ }
38
+
39
+ extension AssSign {
40
+ /// `AVMediaSelectionOption` reports a full locale identifier (`id_ID`, `pt-BR`) where the track was
41
+ /// given a bare language tag. Comparing the language subtag is what makes the two ends meet.
42
+ static func normalizeLanguage(_ language: String) -> String {
43
+ let lowered = language.lowercased()
44
+ if let cut = lowered.firstIndex(where: { $0 == "-" || $0 == "_" }) {
45
+ return String(lowered[lowered.startIndex..<cut])
46
+ }
47
+ return lowered
48
+ }
49
+ }
50
+
51
+ /// A script split into the part this package draws and the part AVFoundation draws.
52
+ struct AssSplit {
53
+ /// Typeset events, drawn by `AssSignOverlayView`.
54
+ let signs: [AssSign]
55
+ /// The script with every sign, drawing and invisible event cut out, for the WebVTT converter.
56
+ let dialogueOnly: String
57
+ }
58
+
59
+ extension SubtitleConverter {
60
+
61
+ /// Takes the typesetting out of a script and hands back what is left.
62
+ ///
63
+ /// ### Why signs cannot go through WebVTT
64
+ ///
65
+ /// AVFoundation renders a `.text` track itself, and WebVTT has no rotation, no scale and no
66
+ /// per-cue opacity to express. `AVTextStyleRule` has no attribute for any of it either. A sign
67
+ /// tilted to lie on a placard comes out flat, which reads as pasted over the picture rather than
68
+ /// part of it — in one episode of Jujutsu Kaisen S3, 3,723 events carry `\frz`, 1,304 of them past
69
+ /// 5°.
70
+ ///
71
+ /// So the same split the Android side makes: typeset events to us, dialogue to the platform, and
72
+ /// the sign lines removed from the copy the platform parses. There is no matching step to get
73
+ /// wrong, because an event is in exactly one of the two outputs.
74
+ ///
75
+ /// ### What counts as a sign
76
+ ///
77
+ /// Anything the author placed: an event carrying `\pos` or `\move`, or whose effective alignment
78
+ /// is not bottom-centre. The complement is dialogue, and AVFoundation is welcome to it.
79
+ static func splitAss(_ ass: String) -> AssSplit {
80
+ let normalized = normalizeNewlines(ass)
81
+ let script = AssScript(parsing: normalized)
82
+ let lines = normalized.components(separatedBy: "\n")
83
+
84
+ var signs: [AssSign] = []
85
+ var removed = Set<Int>()
86
+
87
+ for event in script.events {
88
+ guard event.endMs > event.startMs else { continue }
89
+ let style = script.styles[event.styleName]
90
+
91
+ // A style that draws nothing is where a typesetter parks working notes. Neither renderer
92
+ // should show it, and the WebVTT converter drops it too — cut here so both agree.
93
+ if style?.invisible == true {
94
+ removed.insert(event.lineIndex)
95
+ continue
96
+ }
97
+
98
+ guard let sign = buildSign(event: event, style: style, script: script) else { continue }
99
+
100
+ switch sign {
101
+ case .drop:
102
+ removed.insert(event.lineIndex)
103
+ case .sign(let value):
104
+ signs.append(value)
105
+ removed.insert(event.lineIndex)
106
+ case .dialogue:
107
+ break
108
+ }
109
+ }
110
+
111
+ let dialogueOnly = removed.isEmpty
112
+ ? normalized
113
+ : lines.enumerated().filter { !removed.contains($0.offset) }.map(\.element).joined(separator: "\n")
114
+
115
+ return AssSplit(signs: signs, dialogueOnly: dialogueOnly)
116
+ }
117
+
118
+ private enum SignResolution {
119
+ case sign(AssSign)
120
+ case dialogue
121
+ case drop
122
+ }
123
+
124
+ private static func buildSign(
125
+ event: AssScript.Event,
126
+ style: AssScript.Style?,
127
+ script: AssScript
128
+ ) -> SignResolution? {
129
+ var alignment: Int = style?.alignment ?? 2
130
+ var position: (x: Double, y: Double)?
131
+ var origin: (x: Double, y: Double)?
132
+ var rotationZ: Double = style?.angle ?? 0
133
+ var rotationX: Double = 0
134
+ var rotationY: Double = 0
135
+ var scaleX: Double = style?.scaleX ?? 1
136
+ var scaleY: Double = style?.scaleY ?? 1
137
+ var fontSize: Double = style?.fontSize ?? 48
138
+ var colour: Int = style?.colour ?? 0xFFFFFF
139
+ var alpha: Double = style?.alpha ?? 1
140
+ var bold: Bool = style?.bold ?? false
141
+ var italic: Bool = style?.italic ?? false
142
+ var underline: Bool = style?.underline ?? false
143
+ var isDrawing = false
144
+
145
+ for block in overrideBlocks(in: event.text) {
146
+ for tag in parseOverrideBlock(block) {
147
+ switch tag {
148
+ case .alignment(let value): alignment = value
149
+ case .position(let x, let y): position = (x, y)
150
+ case .origin(let x, let y): origin = (x, y)
151
+ case .rotationZ(let value): rotationZ = value
152
+ case .rotationX(let value): rotationX = value
153
+ case .rotationY(let value): rotationY = value
154
+ case .scaleX(let value): scaleX = value
155
+ case .scaleY(let value): scaleY = value
156
+ case .fontSize(let value): fontSize = value
157
+ case .primaryColour(let value): colour = value
158
+ case .primaryAlpha(let value): alpha = value
159
+ case .bold(let on): bold = on
160
+ case .italic(let on): italic = on
161
+ case .underline(let on): underline = on
162
+ case .drawing: isDrawing = true
163
+ case .reset: break
164
+ }
165
+ }
166
+ }
167
+
168
+ if isDrawing { return .drop }
169
+
170
+ let text = strippedText(event.text)
171
+ if text.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty { return .drop }
172
+
173
+ // The complement of what AVFoundation lays out at the bottom by itself.
174
+ guard position != nil || alignment != 2 else { return .dialogue }
175
+
176
+ // Style margins only. A per-event margin override would need the Event to carry one, and it
177
+ // only ever matters for a sign with no `\pos` at all — which in a typeset script is close to
178
+ // none of them. Media3 ignores per-event margins outright, so both platforms agree here.
179
+ let marginL: Double = style?.marginL ?? 0
180
+ let marginR: Double = style?.marginR ?? 0
181
+ let marginV: Double = style?.marginV ?? 0
182
+
183
+ let x: Double = position?.x ?? {
184
+ switch (alignment - 1) % 3 {
185
+ case 0: return marginL
186
+ case 2: return script.playResX - marginR
187
+ default: return script.playResX / 2
188
+ }
189
+ }()
190
+ let y: Double = position?.y ?? {
191
+ switch (alignment - 1) / 3 {
192
+ case 0: return script.playResY - marginV
193
+ case 2: return marginV
194
+ default: return script.playResY / 2
195
+ }
196
+ }()
197
+
198
+ return .sign(AssSign(
199
+ startMs: event.startMs,
200
+ endMs: event.endMs,
201
+ text: text,
202
+ x: x,
203
+ y: y,
204
+ alignment: alignment,
205
+ // ASS measures counter-clockwise; every renderer here turns clockwise.
206
+ rotationZ: -rotationZ,
207
+ rotationX: rotationX,
208
+ rotationY: -rotationY,
209
+ pivotX: origin?.x ?? x,
210
+ pivotY: origin?.y ?? y,
211
+ scaleX: scaleX,
212
+ scaleY: scaleY,
213
+ fontSize: fontSize,
214
+ colour: colour,
215
+ alpha: alpha,
216
+ outlineColour: style?.outlineColour ?? 0x000000,
217
+ outlineWidth: style?.outlineWidth ?? 0,
218
+ bold: bold,
219
+ italic: italic,
220
+ underline: underline,
221
+ playResX: script.playResX,
222
+ playResY: script.playResY
223
+ ))
224
+ }
225
+
226
+ /// The contents of every `{...}` block, in order.
227
+ private static func overrideBlocks(in text: String) -> [String] {
228
+ var blocks: [String] = []
229
+ let chars = Array(text)
230
+ var i = 0
231
+ while i < chars.count {
232
+ guard chars[i] == "{" else { i += 1; continue }
233
+ guard let close = chars[(i + 1)...].firstIndex(of: "}") else { break }
234
+ blocks.append(String(chars[(i + 1)..<close]))
235
+ i = close + 1
236
+ }
237
+ return blocks
238
+ }
239
+
240
+ /// Override blocks out, ASS escapes in.
241
+ private static func strippedText(_ raw: String) -> String {
242
+ var out = ""
243
+ let chars = Array(raw)
244
+ var i = 0
245
+ while i < chars.count {
246
+ if chars[i] == "{", let close = chars[(i + 1)...].firstIndex(of: "}") {
247
+ i = close + 1
248
+ continue
249
+ }
250
+ if chars[i] == "\\", i + 1 < chars.count {
251
+ switch chars[i + 1] {
252
+ case "N", "n": out.append("\n"); i += 2; continue
253
+ // A hard space: U+00A0, not U+0020. A plain space would let the layout break the line
254
+ // exactly where the typesetter wrote the escape to stop it doing so.
255
+ case "h": out.append("\u{00A0}"); i += 2; continue
256
+ default: break
257
+ }
258
+ }
259
+ out.append(chars[i])
260
+ i += 1
261
+ }
262
+ return out
263
+ }
264
+ }
@@ -0,0 +1,302 @@
1
+ import AVKit
2
+ import ExpoModulesCore
3
+ import UIKit
4
+
5
+ /// Draws ASS typesetting — signs, placards, on-screen labels — with the transforms the script asks
6
+ /// for.
7
+ ///
8
+ /// AVFoundation cannot: it renders a `.text` track itself, WebVTT can express no rotation, scale or
9
+ /// per-cue opacity, and `AVTextStyleRule` has no attribute for any of them. So `SubtitleConverter`
10
+ /// takes those events out of the WebVTT it builds and leaves them in `AssSignStore`, and this view
11
+ /// draws them.
12
+ ///
13
+ /// ### Where it sits
14
+ ///
15
+ /// In `AVPlayerViewController.contentOverlayView`, which is the frame Apple provides between the
16
+ /// video and the transport controls. Sized to `videoBounds` on every frame, so `\pos` — which is in
17
+ /// `PlayResX`/`PlayResY` space — maps onto the picture rather than onto the player, and survives
18
+ /// letterboxing and rotation without any arithmetic of its own.
19
+ ///
20
+ /// ### Why it drives itself from the clock
21
+ ///
22
+ /// Signs do not ride the subtitle track, so nothing announces when one starts. It reads the player's
23
+ /// time on each frame instead, which is also the only granularity that works: frame-by-frame
24
+ /// typesetting writes one event per frame, and a sign on screen for 40 ms has to arrive and leave on
25
+ /// the frame it was authored for.
26
+ final class AssSignOverlayView: UIView {
27
+
28
+ private weak var playerViewController: AVPlayerViewController?
29
+ private var displayLink: CADisplayLink?
30
+
31
+ private var signs: [AssSign] = []
32
+ private var active: [AssSign] = []
33
+ /// What `signs` was resolved from. Both halves matter: the item changes when the next episode
34
+ /// loads, the language when the viewer switches track.
35
+ private var resolvedItem: ObjectIdentifier?
36
+ private var resolvedLanguage: String?
37
+ private var lastLookup: CFTimeInterval = 0
38
+
39
+ /// Rebuilt only when the on-screen set changes, not per frame.
40
+ private var rendered: [RenderedSign] = []
41
+
42
+ private struct RenderedSign {
43
+ let text: NSAttributedString
44
+ let size: CGSize
45
+ let origin: CGPoint
46
+ let transform: CGAffineTransform
47
+ }
48
+
49
+ // MARK: - Lifecycle
50
+
51
+ init(playerViewController: AVPlayerViewController) {
52
+ self.playerViewController = playerViewController
53
+ super.init(frame: .zero)
54
+ isUserInteractionEnabled = false
55
+ backgroundColor = .clear
56
+ contentMode = .redraw
57
+ }
58
+
59
+ @available(*, unavailable)
60
+ required init?(coder: NSCoder) {
61
+ fatalError("init(coder:) is not used")
62
+ }
63
+
64
+ /// Adds the overlay to the controller's content overlay, or returns the one already there.
65
+ @discardableResult
66
+ static func install(in controller: AVPlayerViewController) -> AssSignOverlayView? {
67
+ guard let host = controller.contentOverlayView else { return nil }
68
+
69
+ for subview in host.subviews {
70
+ if let existing = subview as? AssSignOverlayView { return existing }
71
+ }
72
+
73
+ let overlay = AssSignOverlayView(playerViewController: controller)
74
+ host.addSubview(overlay)
75
+ overlay.start()
76
+ return overlay
77
+ }
78
+
79
+ static func remove(from controller: AVPlayerViewController) {
80
+ guard let host = controller.contentOverlayView else { return }
81
+ for subview in host.subviews {
82
+ guard let overlay = subview as? AssSignOverlayView else { continue }
83
+ overlay.stop()
84
+ overlay.removeFromSuperview()
85
+ }
86
+ }
87
+
88
+ private func start() {
89
+ guard displayLink == nil else { return }
90
+ let link = CADisplayLink(target: self, selector: #selector(tick))
91
+ link.add(to: .main, forMode: .common)
92
+ displayLink = link
93
+ }
94
+
95
+ private func stop() {
96
+ displayLink?.invalidate()
97
+ displayLink = nil
98
+ clearResolved()
99
+ }
100
+
101
+ override func willMove(toWindow newWindow: UIWindow?) {
102
+ super.willMove(toWindow: newWindow)
103
+ if newWindow == nil {
104
+ displayLink?.invalidate()
105
+ displayLink = nil
106
+ } else {
107
+ start()
108
+ }
109
+ }
110
+
111
+ // MARK: - The clock
112
+
113
+ @objc private func tick() {
114
+ guard let controller = playerViewController, let player = controller.player else { return }
115
+
116
+ // The picture moves under us: rotation, full screen, and a letterbox that changes with the
117
+ // video's own aspect all land here rather than in any positioning arithmetic.
118
+ let bounds = controller.videoBounds
119
+ if bounds != frame, bounds.width > 0, bounds.height > 0 {
120
+ frame = bounds
121
+ // Every measurement in `prepare` is in view pixels, so all of it is stale after a resize.
122
+ rendered = []
123
+ setNeedsDisplay()
124
+ }
125
+
126
+ // Re-resolved on a timer rather than only when empty. Gating on "no signs yet" is the same
127
+ // mistake twice: it never fires again once a video with typesetting has loaded, so the next
128
+ // episode inherits the previous one's signs and matches them against its own timeline. The
129
+ // Android side shipped exactly that and it took two releases to unpick.
130
+ let now = CACurrentMediaTime()
131
+ if now - lastLookup >= AssSignOverlayView.lookupInterval {
132
+ lastLookup = now
133
+ resolveSigns(for: player)
134
+ }
135
+ if signs.isEmpty { return }
136
+
137
+ let positionMs = Int(CMTimeGetSeconds(player.currentTime()) * 1000)
138
+ setActive(signs.filter { positionMs >= $0.startMs && positionMs < $0.endMs })
139
+ }
140
+
141
+ private func resolveSigns(for player: AVPlayer) {
142
+ guard let item = player.currentItem else {
143
+ if resolvedItem != nil { clearResolved() }
144
+ return
145
+ }
146
+
147
+ let identity = ObjectIdentifier(item)
148
+ let language = selectedSubtitleLanguage(of: item)
149
+ guard identity != resolvedItem || language != resolvedLanguage else { return }
150
+
151
+ resolvedItem = identity
152
+ resolvedLanguage = language
153
+
154
+ // Typesetting rides on the item that owns it, so there is no key to mismatch and no store to go
155
+ // stale: a different item simply has different signs.
156
+ let found = (item as? VideoPlayerItem)
157
+ .flatMap { playerItem in language.map { playerItem.assSigns[AssSign.normalizeLanguage($0)] } }
158
+ ?? nil
159
+
160
+ signs = found ?? []
161
+ rendered = []
162
+ setActive([])
163
+ log.info("[expo-video] ASS signs for \(language ?? "nil"): \(signs.count)")
164
+ }
165
+
166
+ private func clearResolved() {
167
+ resolvedItem = nil
168
+ resolvedLanguage = nil
169
+ signs = []
170
+ rendered = []
171
+ setActive([])
172
+ }
173
+
174
+ /// The locale of the selected legible option, which is the only identity a sideloaded track keeps
175
+ /// once it is inside the composition.
176
+ private func selectedSubtitleLanguage(of item: AVPlayerItem) -> String? {
177
+ guard let group = item.asset.mediaSelectionGroup(forMediaCharacteristic: .legible),
178
+ let option = item.currentMediaSelection.selectedMediaOption(in: group) else {
179
+ return nil
180
+ }
181
+ return option.locale?.identifier ?? option.extendedLanguageTag
182
+ }
183
+
184
+ private func setActive(_ next: [AssSign]) {
185
+ // Value equality across the whole sign, not a (time, text) stand-in: two events can share both
186
+ // and differ in where they sit, which is the case a frame-by-frame animation is made of.
187
+ guard next != active else { return }
188
+ active = next
189
+ rendered = []
190
+ setNeedsDisplay()
191
+ }
192
+
193
+ // MARK: - Drawing
194
+
195
+ override func draw(_ rect: CGRect) {
196
+ guard let context = UIGraphicsGetCurrentContext(), bounds.width > 0, bounds.height > 0 else { return }
197
+
198
+ if rendered.isEmpty && !active.isEmpty {
199
+ rendered = active.compactMap(prepare)
200
+ }
201
+
202
+ for sign in rendered {
203
+ context.saveGState()
204
+ context.concatenate(sign.transform)
205
+ sign.text.draw(at: sign.origin)
206
+ context.restoreGState()
207
+ }
208
+ }
209
+
210
+ private func prepare(_ sign: AssSign) -> RenderedSign? {
211
+ let alpha = max(0, min(1, sign.alpha))
212
+ if alpha == 0 { return nil }
213
+
214
+ // Script space to view space. Both axes scale independently, so a script authored at a different
215
+ // aspect ratio than the video still lands where it was drawn.
216
+ let scaleToViewX = bounds.width / CGFloat(sign.playResX)
217
+ let scaleToViewY = bounds.height / CGFloat(sign.playResY)
218
+
219
+ let attributed = attributedText(for: sign, scaleToViewY: scaleToViewY, alpha: alpha)
220
+ let size = attributed.boundingRect(
221
+ with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude),
222
+ options: [.usesLineFragmentOrigin, .usesFontLeading],
223
+ context: nil
224
+ ).size
225
+
226
+ let anchorX = CGFloat(sign.x) * scaleToViewX
227
+ let anchorY = CGFloat(sign.y) * scaleToViewY
228
+ // \pos names the point the cue's alignment corner sits on, so the box is offset from it.
229
+ let left: CGFloat
230
+ switch (sign.alignment - 1) % 3 {
231
+ case 0: left = anchorX
232
+ case 2: left = anchorX - size.width
233
+ default: left = anchorX - size.width / 2
234
+ }
235
+ let top: CGFloat
236
+ switch (sign.alignment - 1) / 3 {
237
+ case 0: top = anchorY - size.height
238
+ case 2: top = anchorY
239
+ default: top = anchorY - size.height / 2
240
+ }
241
+
242
+ // \org moves the centre of rotation away from the text, which is how a sign stays glued to a
243
+ // placard pivoting off-screen. Without it every rotation would spin about the text's own middle.
244
+ let pivotX = CGFloat(sign.pivotX) * scaleToViewX
245
+ let pivotY = CGFloat(sign.pivotY) * scaleToViewY
246
+
247
+ // Scale first, then turn — ASS scales the glyph and then rotates it, and with an anisotropic
248
+ // scale the two do not commute. `concatenating` applies the receiver first, so the order reads
249
+ // in the order it happens.
250
+ let transform = CGAffineTransform(translationX: -pivotX, y: -pivotY)
251
+ .concatenating(CGAffineTransform(scaleX: CGFloat(sign.scaleX), y: CGFloat(sign.scaleY)))
252
+ .concatenating(CGAffineTransform(rotationAngle: CGFloat(sign.rotationZ) * .pi / 180))
253
+ .concatenating(CGAffineTransform(translationX: pivotX, y: pivotY))
254
+
255
+ return RenderedSign(text: attributed, size: size, origin: CGPoint(x: left, y: top), transform: transform)
256
+ }
257
+
258
+ private func attributedText(for sign: AssSign, scaleToViewY: CGFloat, alpha: Double) -> NSAttributedString {
259
+ let pointSize = max(1, CGFloat(sign.fontSize) * scaleToViewY)
260
+ var font = UIFont.systemFont(ofSize: pointSize, weight: sign.bold ? .bold : .regular)
261
+ if sign.italic, let descriptor = font.fontDescriptor.withSymbolicTraits(.traitItalic) {
262
+ font = UIFont(descriptor: descriptor, size: pointSize)
263
+ }
264
+
265
+ let paragraph = NSMutableParagraphStyle()
266
+ switch (sign.alignment - 1) % 3 {
267
+ case 0: paragraph.alignment = .left
268
+ case 2: paragraph.alignment = .right
269
+ default: paragraph.alignment = .center
270
+ }
271
+
272
+ var attributes: [NSAttributedString.Key: Any] = [
273
+ .font: font,
274
+ .foregroundColor: colour(sign.colour, alpha: alpha),
275
+ .paragraphStyle: paragraph
276
+ ]
277
+ if sign.underline {
278
+ attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
279
+ }
280
+ if sign.outlineWidth > 0 {
281
+ attributes[.strokeColor] = colour(sign.outlineColour, alpha: alpha)
282
+ // Negative means stroke *and* fill. A positive width would draw the outline only, leaving the
283
+ // letters hollow.
284
+ attributes[.strokeWidth] = -sign.outlineWidth * 2
285
+ }
286
+
287
+ return NSAttributedString(string: sign.text, attributes: attributes)
288
+ }
289
+
290
+ /// ASS colours carry no alpha byte; opacity travels separately.
291
+ private func colour(_ rgb: Int, alpha: Double) -> UIColor {
292
+ UIColor(
293
+ red: CGFloat((rgb >> 16) & 0xFF) / 255,
294
+ green: CGFloat((rgb >> 8) & 0xFF) / 255,
295
+ blue: CGFloat(rgb & 0xFF) / 255,
296
+ alpha: CGFloat(alpha)
297
+ )
298
+ }
299
+
300
+ /// How often to ask the store for signs that have not been parsed yet.
301
+ private static let lookupInterval: CFTimeInterval = 0.25
302
+ }
@@ -285,12 +285,14 @@ internal struct RenderedSubtitleText {
285
285
  var isDrawing: Bool
286
286
  }
287
287
 
288
- private enum InlineToken {
288
+ internal enum InlineToken {
289
289
  case text(String)
290
290
  case markup(String)
291
291
  }
292
292
 
293
- private extension SubtitleConverter {
293
+ // Internal rather than private: `AssSign.swift` reuses the override-block scanner and the colour
294
+ // parser, and reimplementing either would be two copies that have to agree about ASS forever.
295
+ internal extension SubtitleConverter {
294
296
  /// Escapes only the parts of a cue that came from the source text.
295
297
  ///
296
298
  /// Order matters: `&` first, then `<`, then `>`. Escaping `&` last would double-escape the one in
@@ -370,6 +372,9 @@ private extension SubtitleConverter {
370
372
  case .underline(let on): on ? openTag("u") : closeTag("u")
371
373
  case .drawing: isDrawing = true
372
374
  case .reset: closeAll()
375
+ // The transform tags are for signs, which never reach this converter — a sign is taken
376
+ // out of the script before the WebVTT is built, because WebVTT can express none of them.
377
+ default: break
373
378
  }
374
379
  }
375
380
  i = end + 1
@@ -479,6 +484,50 @@ private extension SubtitleConverter {
479
484
  case underline(Bool)
480
485
  case drawing
481
486
  case reset
487
+ // Read for signs only. The WebVTT path ignores every one of them, because WebVTT can express
488
+ // none of it — which is the whole reason signs are drawn separately.
489
+ case origin(Double, Double)
490
+ case rotationZ(Double)
491
+ case rotationX(Double)
492
+ case rotationY(Double)
493
+ case scaleX(Double)
494
+ case scaleY(Double)
495
+ case fontSize(Double)
496
+ case primaryColour(Int)
497
+ case primaryAlpha(Double)
498
+ case bold(Bool)
499
+ }
500
+
501
+ struct AssColour {
502
+ let rgb: Int
503
+ let alpha: Double
504
+ }
505
+
506
+ /// `&HAABBGGRR&` or `&HBBGGRR&`, with or without the ampersands.
507
+ ///
508
+ /// The bytes are reversed against RGB and the alpha byte is inverted — `FF` is fully transparent.
509
+ /// A six-digit value carries no alpha byte and is therefore opaque.
510
+ static func parseAssColour(_ value: String, alphaOnly: Bool = false) -> AssColour? {
511
+ var text = value.trimmingCharacters(in: .whitespaces)
512
+ if text.hasSuffix("&") { text = String(text.dropLast()) }
513
+ if text.lowercased().hasPrefix("&h") { text = String(text.dropFirst(2)) }
514
+ guard !text.isEmpty, let hex = UInt32(text, radix: 16) else { return nil }
515
+
516
+ if alphaOnly || text.count <= 2 {
517
+ return AssColour(rgb: 0xFFFFFF, alpha: 1 - Double(hex & 0xFF) / 255)
518
+ }
519
+
520
+ let red: UInt32 = hex & 0xFF
521
+ let green: UInt32 = (hex >> 8) & 0xFF
522
+ let blue: UInt32 = (hex >> 16) & 0xFF
523
+ let rgb: Int = Int(red) << 16 | Int(green) << 8 | Int(blue)
524
+
525
+ var alpha: Double = 1
526
+ if text.count > 6 {
527
+ let transparency: UInt32 = (hex >> 24) & 0xFF
528
+ alpha = 1 - Double(transparency) / 255
529
+ }
530
+ return AssColour(rgb: rgb, alpha: alpha)
482
531
  }
483
532
 
484
533
  /// Scans one `{...}` block left to right.
@@ -487,6 +536,14 @@ private extension SubtitleConverter {
487
536
  /// one pass and the `\i1` keeps its position within the text. Reading the tag name as a run of
488
537
  /// letters is also what makes the longest-match problem disappear: `\c` cannot eat `\clip` and
489
538
  /// `\b` cannot eat `\bord`, because those parse as the names "clip" and "bord".
539
+ /// A number, or nothing. `Double` parses "nan" and "inf", and neither belongs in a transform.
540
+ static func finite(_ text: String) -> Double? {
541
+ guard let value = Double(text.trimmingCharacters(in: CharacterSet.whitespaces)), value.isFinite else {
542
+ return nil
543
+ }
544
+ return value
545
+ }
546
+
490
547
  static func parseOverrideBlock(_ block: String) -> [OverrideTag] {
491
548
  var tags: [OverrideTag] = []
492
549
  let chars = Array(block)
@@ -497,6 +554,10 @@ private extension SubtitleConverter {
497
554
  i += 1
498
555
 
499
556
  var name = ""
557
+ // A leading digit belongs to the name: \1c, \1a and \4a all start with one. Reading only
558
+ // letters left those as the empty name, which was harmless while colour was being dropped and
559
+ // is not once signs need it.
560
+ while i < chars.count, chars[i].isNumber { name.append(chars[i]); i += 1 }
500
561
  while i < chars.count, chars[i].isLetter { name.append(chars[i]); i += 1 }
501
562
 
502
563
  var argument = ""
@@ -526,9 +587,7 @@ private extension SubtitleConverter {
526
587
  tags.append(.alignment(value))
527
588
  }
528
589
  case "pos":
529
- let parts = argument.split(separator: ",").map {
530
- Double($0.trimmingCharacters(in: .whitespaces)) ?? 0
531
- }
590
+ let parts = argument.split(separator: ",").compactMap { finite(String($0)) }
532
591
  if parts.count >= 2 { tags.append(.position(parts[0], parts[1])) }
533
592
  case "i": tags.append(.italic(argument.trimmingCharacters(in: .whitespaces) == "1"))
534
593
  case "u": tags.append(.underline(argument.trimmingCharacters(in: .whitespaces) == "1"))
@@ -539,6 +598,34 @@ private extension SubtitleConverter {
539
598
  tags.append(.drawing)
540
599
  }
541
600
  case "r": tags.append(.reset)
601
+ case "move":
602
+ // A move ends where it ends; drawing it at the destination beats drawing it at the start.
603
+ let parts = argument.split(separator: ",").compactMap { finite(String($0)) }
604
+ if parts.count >= 4 { tags.append(.position(parts[2], parts[3])) }
605
+ case "org":
606
+ let parts = argument.split(separator: ",").compactMap { finite(String($0)) }
607
+ if parts.count >= 2 { tags.append(.origin(parts[0], parts[1])) }
608
+ // `Double("nan")` and `Double("inf")` parse. A subtitle is untrusted scraper output, and a
609
+ // non-finite value carried into a transform makes the sign silently fail to draw — so every
610
+ // number here goes through `finite`.
611
+ case "frz", "fr":
612
+ if let value = finite(argument) { tags.append(.rotationZ(value)) }
613
+ case "frx":
614
+ if let value = finite(argument) { tags.append(.rotationX(value)) }
615
+ case "fry":
616
+ if let value = finite(argument) { tags.append(.rotationY(value)) }
617
+ case "fscx":
618
+ if let value = finite(argument) { tags.append(.scaleX(value / 100)) }
619
+ case "fscy":
620
+ if let value = finite(argument) { tags.append(.scaleY(value / 100)) }
621
+ case "fs":
622
+ if let value = finite(argument), value > 0 { tags.append(.fontSize(value)) }
623
+ case "c", "1c":
624
+ if let colour = parseAssColour(argument) { tags.append(.primaryColour(colour.rgb)) }
625
+ case "alpha", "1a":
626
+ if let colour = parseAssColour(argument, alphaOnly: true) { tags.append(.primaryAlpha(colour.alpha)) }
627
+ case "b":
628
+ tags.append(.bold(argument.trimmingCharacters(in: .whitespaces) != "0"))
542
629
  default: break
543
630
  }
544
631
  }
@@ -610,16 +697,35 @@ private extension SubtitleConverter {
610
697
  /// Header fields are read from their own `Format:` lines rather than assumed, because encoders
611
698
  /// reorder them and reading the Text column one position off produces a file that still parses and
612
699
  /// is entirely wrong.
613
- private struct AssScript {
700
+ internal struct AssScript {
614
701
  struct Style {
702
+
615
703
  var alignment: Int
616
704
  var italic: Bool
617
705
  var underline: Bool
618
706
  /// The author asked for this style to draw nothing. See `isInvisibleStyle`.
619
707
  var invisible: Bool
708
+ // Read for signs. The WebVTT path uses none of it: colour, size and weight are the app's to
709
+ // decide there, and a sign is the one case where they are not.
710
+ var fontSize: Double = 48
711
+ var colour: Int = 0xFFFFFF
712
+ var alpha: Double = 1
713
+ var outlineColour: Int = 0x000000
714
+ var outlineWidth: Double = 0
715
+ var bold: Bool = false
716
+ var scaleX: Double = 1
717
+ var scaleY: Double = 1
718
+ var angle: Double = 0
719
+ var marginL: Double = 0
720
+ var marginR: Double = 0
721
+ var marginV: Double = 0
620
722
  }
621
723
 
622
724
  struct Event {
725
+ /// Index of the line this came from, so a sign can be cut out of the script handed to the
726
+ /// WebVTT converter. Mirrors the split on Android; it is what makes drawing an event twice
727
+ /// structurally impossible rather than merely avoided.
728
+ var lineIndex: Int = -1
623
729
  var startMs: Int
624
730
  var endMs: Int
625
731
  var styleName: String
@@ -639,7 +745,7 @@ private struct AssScript {
639
745
  var sawPlayResX = false
640
746
  var sawPlayResY = false
641
747
 
642
- for rawLine in content.components(separatedBy: "\n") {
748
+ for (lineIndex, rawLine) in content.components(separatedBy: "\n").enumerated() {
643
749
  let line = rawLine.trimmingCharacters(in: .whitespaces)
644
750
  if line.isEmpty || line.hasPrefix(";") { continue }
645
751
 
@@ -685,7 +791,19 @@ private struct AssScript {
685
791
  : ((1...9).contains(rawAlignment) ? rawAlignment : 2),
686
792
  italic: field("italic") == "-1",
687
793
  underline: field("underline") == "-1",
688
- invisible: Self.isInvisibleStyle(fontSize: field("fontsize"), primaryColour: field("primarycolour"))
794
+ invisible: Self.isInvisibleStyle(fontSize: field("fontsize"), primaryColour: field("primarycolour")),
795
+ fontSize: field("fontsize").flatMap(SubtitleConverter.finite) ?? 48,
796
+ colour: field("primarycolour").flatMap { SubtitleConverter.parseAssColour($0)?.rgb } ?? 0xFFFFFF,
797
+ alpha: field("primarycolour").flatMap { SubtitleConverter.parseAssColour($0)?.alpha } ?? 1,
798
+ outlineColour: field("outlinecolour").flatMap { SubtitleConverter.parseAssColour($0)?.rgb } ?? 0x000000,
799
+ outlineWidth: field("outline").flatMap(SubtitleConverter.finite) ?? 0,
800
+ bold: field("bold") == "-1",
801
+ scaleX: (field("scalex").flatMap(SubtitleConverter.finite) ?? 100) / 100,
802
+ scaleY: (field("scaley").flatMap(SubtitleConverter.finite) ?? 100) / 100,
803
+ angle: field("angle").flatMap(SubtitleConverter.finite) ?? 0,
804
+ marginL: field("marginl").flatMap(SubtitleConverter.finite) ?? 0,
805
+ marginR: field("marginr").flatMap(SubtitleConverter.finite) ?? 0,
806
+ marginV: field("marginv").flatMap(SubtitleConverter.finite) ?? 0
689
807
  )
690
808
  }
691
809
 
@@ -697,7 +815,8 @@ private struct AssScript {
697
815
  } else if key.caseInsensitiveCompare("Dialogue") == .orderedSame {
698
816
  // Only Dialogue reaches the screen. Comment carries typesetting notes and unused
699
817
  // alternates, and Picture/Sound/Movie/Command are not text at all.
700
- guard let event = Self.parseEvent(value, fields: eventFields) else { continue }
818
+ guard var event = Self.parseEvent(value, fields: eventFields) else { continue }
819
+ event.lineIndex = lineIndex
701
820
  events.append(event)
702
821
  }
703
822
 
@@ -7,6 +7,13 @@ class VideoPlayerItem: AVPlayerItem {
7
7
  let urlAsset: VideoAsset
8
8
  let videoSource: VideoSource
9
9
  let isHls: Bool
10
+ /// ASS typesetting taken out of this item's subtitle tracks, keyed by language subtag.
11
+ ///
12
+ /// Carried on the item rather than in a store of its own, because the item *is* the identity the
13
+ /// overlay needs: two players can be loading different videos at once, and a store keyed by
14
+ /// anything a subtitle track keeps inside a composition — which is only its language — would have
15
+ /// them overwrite each other.
16
+ let assSigns: [String: [AssSign]]
10
17
  var videoTracks: [VideoTrack] {
11
18
  get async {
12
19
  return await tracksLoadingTask?.value ?? []
@@ -24,6 +31,8 @@ class VideoPlayerItem: AVPlayerItem {
24
31
  let asset = VideoAsset(url: url, videoSource: videoSource)
25
32
  self.urlAsset = asset
26
33
  self.isHls = asset.effectivePlaybackURL.isHLS || asset.effectiveContentType == .hls
34
+ // This initializer builds no composition, so it sideloads nothing and has no typesetting.
35
+ self.assSigns = [:]
27
36
  super.init(asset: urlAsset, automaticallyLoadedAssetKeys: nil)
28
37
  self.createTracksLoadingTask()
29
38
  }
@@ -51,15 +60,18 @@ class VideoPlayerItem: AVPlayerItem {
51
60
  // (non-HLS, non-DRM) sources — HLS declares its own subtitle renditions, and a composition would
52
61
  // bypass the DRM/resource-loader path used by protected content.
53
62
  var playbackAsset: AVAsset = asset
63
+ var signs: [String: [AssSign]] = [:]
54
64
  if let subtitleTracks = videoSource.subtitleTracks, !subtitleTracks.isEmpty, !isHls, videoSource.drm == nil {
55
- if let composition = await VideoPlayerSubtitleSideload.buildComposition(
65
+ if let sideloaded = await VideoPlayerSubtitleSideload.buildComposition(
56
66
  from: asset,
57
67
  subtitleTracks: subtitleTracks,
58
68
  headers: videoSource.headers
59
69
  ) {
60
- playbackAsset = composition
70
+ playbackAsset = sideloaded.composition
71
+ signs = sideloaded.signsByLanguage
61
72
  }
62
73
  }
74
+ self.assSigns = signs
63
75
 
64
76
  super.init(asset: playbackAsset, automaticallyLoadedAssetKeys: nil)
65
77
  self.createTracksLoadingTask()
@@ -13,14 +13,27 @@ import ExpoModulesCore
13
13
  /// pipeline already understands. Only WebVTT is natively supported, so `SRT` files are converted first, and
14
14
  /// remote files are downloaded to a temporary location because `insertTimeRange` requires a readable local track.
15
15
  internal enum VideoPlayerSubtitleSideload {
16
+ /// A composition plus the typesetting taken out of its subtitle tracks, keyed by language.
17
+ ///
18
+ /// The signs travel with the composition rather than through a store of their own. A store would
19
+ /// have to be keyed by something both ends can see, and the only identity a sideloaded track keeps
20
+ /// inside a composition is its language — which means two players loading different videos in the
21
+ /// same language would overwrite each other, and a second episode would inherit the first one's
22
+ /// signs. Handing them back here makes the `AVPlayerItem` itself the identity.
23
+ struct SideloadResult {
24
+ let composition: AVMutableComposition
25
+ let signsByLanguage: [String: [AssSign]]
26
+ }
27
+
16
28
  static func buildComposition(
17
29
  from asset: AVURLAsset,
18
30
  subtitleTracks: [SubtitleSource],
19
31
  headers: [String: String]?
20
- ) async -> AVMutableComposition? {
32
+ ) async -> SideloadResult? {
21
33
  do {
22
34
  let mainDuration = try await asset.load(.duration)
23
35
  let composition = AVMutableComposition()
36
+ var signsByLanguage: [String: [AssSign]] = [:]
24
37
 
25
38
  // Copy the original audio + video tracks.
26
39
  let tracks = try await asset.load(.tracks)
@@ -54,7 +67,7 @@ internal enum VideoPlayerSubtitleSideload {
54
67
 
55
68
  var didAddSubtitle = false
56
69
  for subtitle in subtitleTracks {
57
- guard let localURL = await resolveLocalVttURL(for: subtitle, headers: headers) else {
70
+ guard let localURL = await resolveLocalVttURL(for: subtitle, headers: headers, signs: &signsByLanguage) else {
58
71
  continue
59
72
  }
60
73
  let subtitleAsset = AVURLAsset(url: localURL)
@@ -94,7 +107,7 @@ internal enum VideoPlayerSubtitleSideload {
94
107
  }
95
108
  }
96
109
 
97
- return didAddSubtitle ? composition : nil
110
+ return didAddSubtitle ? SideloadResult(composition: composition, signsByLanguage: signsByLanguage) : nil
98
111
  } catch {
99
112
  log.warn("[expo-video] Failed to build subtitle composition: \(error.localizedDescription)")
100
113
  return nil
@@ -108,7 +121,11 @@ internal enum VideoPlayerSubtitleSideload {
108
121
  /// also preserved the colours and weights a WebVTT file carries in `<c.yellow>` and `<b>`, which
109
122
  /// would then beat `subtitleStyle`. The temp-file cache means the work happens once per
110
123
  /// (uri, language, format, offset).
111
- private static func resolveLocalVttURL(for subtitle: SubtitleSource, headers: [String: String]?) async -> URL? {
124
+ private static func resolveLocalVttURL(
125
+ for subtitle: SubtitleSource,
126
+ headers: [String: String]?,
127
+ signs: inout [String: [AssSign]]
128
+ ) async -> URL? {
112
129
  guard let uri = subtitle.uri else {
113
130
  return nil
114
131
  }
@@ -130,7 +147,15 @@ internal enum VideoPlayerSubtitleSideload {
130
147
  }
131
148
 
132
149
  switch subtitle.resolvedFormat {
133
- case .ass: text = SubtitleConverter.assToVtt(text)
150
+ case .ass:
151
+ // Typesetting is taken out here rather than converted. WebVTT can express no rotation, scale
152
+ // or per-cue opacity, and AVFoundation renders the track itself, so a sign that went through
153
+ // would come out flat. `AssSignOverlayView` draws them instead.
154
+ let split = SubtitleConverter.splitAss(text)
155
+ if !split.signs.isEmpty {
156
+ signs[AssSign.normalizeLanguage(subtitle.language ?? "und")] = split.signs
157
+ }
158
+ text = SubtitleConverter.assToVtt(split.dialogueOnly)
134
159
  case .srt: text = SubtitleConverter.srtToVtt(text)
135
160
  case .vtt: text = SubtitleConverter.sanitizeVtt(text)
136
161
  }
@@ -11,6 +11,14 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
11
11
  playerViewController.player = player?.ref
12
12
  // Forward any subtitle style set before the player was attached.
13
13
  player?.subtitleStyle = subtitleStyle
14
+ // ASS typesetting is drawn by us, not by AVFoundation, which can express none of its
15
+ // transforms. Installed whenever a player arrives and torn down with the view; the overlay
16
+ // sits idle and costs a display-link tick until a track with signs is actually loaded.
17
+ if player != nil {
18
+ AssSignOverlayView.install(in: playerViewController)
19
+ } else {
20
+ AssSignOverlayView.remove(from: playerViewController)
21
+ }
14
22
  }
15
23
  }
16
24
 
@@ -74,6 +82,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
74
82
  deinit {
75
83
  VideoManager.shared.unregister(videoView: self)
76
84
  removeFirstFrameObserver()
85
+ AssSignOverlayView.remove(from: playerViewController)
77
86
  }
78
87
 
79
88
  func enterFullscreen() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-video-subtitle",
3
- "version": "0.7.5",
3
+ "version": "0.8.0",
4
4
  "description": "A cross-platform, performant video component for React Native and Expo with external subtitle sideloading and custom subtitle styling.",
5
5
  "keywords": [
6
6
  "expo",