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 +72 -0
- package/android/src/main/java/expo/modules/video/utils/AssSignOverlay.kt +16 -1
- package/android/src/main/java/expo/modules/video/utils/AssSignStore.kt +23 -2
- package/android/src/main/java/expo/modules/video/utils/StackingSubtitleParser.kt +4 -1
- package/ios/AssSign.swift +264 -0
- package/ios/AssSignOverlayView.swift +302 -0
- package/ios/SubtitleConverterText.swift +128 -9
- package/ios/VideoPlayerItem.swift +14 -2
- package/ios/VideoPlayerSubtitleSideload.swift +30 -5
- package/ios/VideoView.swift +9 -0
- package/package.json +1 -1
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)
|
|
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
|
-
|
|
288
|
+
internal enum InlineToken {
|
|
289
289
|
case text(String)
|
|
290
290
|
case markup(String)
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
-
private
|
|
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: ",").
|
|
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
|
-
|
|
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
|
|
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
|
|
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 ->
|
|
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(
|
|
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:
|
|
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
|
}
|
package/ios/VideoView.swift
CHANGED
|
@@ -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