expo-video-subtitle 0.5.0 → 0.6.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,46 @@ 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.6.0
11
+
12
+ ### Fixed
13
+
14
+ - **ASS dialogue keeps working with `bottomOffset` and with cue stacking again (Android).** Media3's
15
+ `SsaParser` calls `setLine` and `setPosition` on *every* cue, including the bottom-centre dialogue
16
+ the author placed nothing on. `SubtitlePainter` applies `bottomPaddingFraction` only to cues whose
17
+ `line` is unset, so `SubtitleSource.bottomOffset` silently stopped moving anything on an `.ass`
18
+ track, and `StackingSubtitleParser` treated every cue as deliberately placed and left simultaneous
19
+ speakers drawing on top of each other.
20
+
21
+ A new `SsaCueNormalizer` clears the placement Media3 filled in. Recognising it is exact rather
22
+ than a guess: the values are a pure function of the cue's anchors, so a cue matching both defaults
23
+ for the bottom-centre anchors is precisely a cue with no positioning tag. Anything the author did
24
+ place — `\an8`, `\pos`, a style aligned elsewhere — keeps everything and still bypasses stacking.
25
+
26
+ - **Signs keep the colours they were typeset in (Android).** The span filter dropped colour from
27
+ every cue so the app's own subtitle style would win, which is right for dialogue and wrong for a
28
+ sign: typesetting is drawn onto the artwork in colours chosen to sit on it, and the dark lettering
29
+ on the white placards in Jujutsu Kaisen S3E3 became white-on-white. Cues an ASS track positions
30
+ itself now keep their appearance; unpositioned dialogue still loses its own and follows the
31
+ viewer's settings. Only ASS opts in — it is the one format where positioning reliably means
32
+ "sign" rather than a cue setting on ordinary dialogue.
33
+
34
+ - **Typesetters' working notes stay hidden.** A script ships its production notes on a style that
35
+ renders nothing: JJK S3E3 carries `Style: f0ld3r-invisible` with `Fontsize 0` and every colour
36
+ `&HFF……`, holding 158 events of "--mahito", "#1", "check in frame 6212". libass honours both
37
+ signals. Media3 turns a transparent primary colour into a `ForegroundColorSpan` with alpha 0,
38
+ which the span filter then strips, so the note came back in the viewer's colour at the viewer's
39
+ size; the iOS converter never read either field, and WebVTT cannot express opacity anyway. Events
40
+ on such a style are now dropped on both platforms, before anything can make them visible.
41
+
42
+ ### Known limitations
43
+
44
+ - Sign **font size** is still the app's, not the script's. Media3 does read `Fontsize` into
45
+ `Cue.textSize`, but the filter clears it so the two subtitle views measure the same glyphs; giving
46
+ it back to signs needs the rounded-background view checked on a device first.
47
+ - Blur, borders, shadows, rotation and `\t` animation are still dropped. Matching libass output
48
+ exactly needs libass, not Media3.
49
+
10
50
  ## 0.5.0
11
51
 
12
52
  ### Added
package/FORK.md CHANGED
@@ -27,7 +27,8 @@ Measured against the published `expo-video@56.1.4` tarball. `+`/`-` are added/re
27
27
  | `android/…/utils/SubtitleSpanFilter.kt` | 135 | Strips colour, size, family and weight from every cue of every format, keeping italic/underline/strikethrough and all positioning, plus `FilteringSubtitleParser` which applies it to whatever a delegate emits. **Load-bearing fact, verified against Media3 1.9.0 bytecode:** `SubtitleViewUtils.removeAllEmbeddedStyling` touches only `clearWindowColor`, `getText`/`setText` and `setTextSize` — never `line`, `position` or `textAlignment`. That is why the filter can live in the parser and the view flag can stay on; if an upstream sync changes it, this whole design has to be revisited. Uses an **allowlist**, because a denylist misses `TextAppearanceSpan` (colour + size + family in one span). `StyleSpan` needs a rewrite rather than a keep/drop: `BOLD_ITALIC` has to come back as plain `ITALIC` over the same range. |
28
28
  | `android/…/records/SubtitleSource.kt` | 63 | Record → `MediaItem.SubtitleConfiguration` + MIME resolution. |
29
29
  | `android/…/records/SubtitleStyle.kt` | 152 | Record → `CaptionStyleCompat`; hex colour parsing; font-family resolution (`res/font` → `ReactFontManager`); the `applyEmbeddedStyles` flag, which `SubtitleUtils` reads directly rather than `toCaptionStyle` and which defaults to `true` since 0.5.0. |
30
- | `android/…/utils/StackingSubtitleParser.kt` | 308 | `SubtitleParser` for SubRip that segments the cue timeline and merges simultaneous cues into one stacked block, plus the `SubtitleParser.Factory` that routes SubRip to it and wraps every parser in `FilteringSubtitleParser`. SSA/ASS is deliberately not stacked: `SsaParser` sets both `line` and `position` on every cue, so they would all take the passthrough branch anyway. Assigns each cue a vertical slot it keeps for its whole time on screen — a finished cue's line is held blank (`\u200B`) so the cues above it do not drop. Merges through a `SpannableStringBuilder` so cue spans survive. |
30
+ | `android/…/utils/StackingSubtitleParser.kt` | 342 | `SubtitleParser` that segments the cue timeline and merges simultaneous **unpositioned** cues into one stacked block, plus the `SubtitleParser.Factory` that routes SubRip and ASS/SSA to it and wraps every parser in `FilteringSubtitleParser`. ASS gets there through `SsaCueNormalizer` as the `prepare` step — without it `SsaParser` sets both `line` and `position` on every cue and they would all take the passthrough branch. Assigns each cue a vertical slot it keeps for its whole time on screen — a finished cue's line is held blank (`\u200B`) so the cues above it do not drop. Merges through a `SpannableStringBuilder` so cue spans survive. |
31
+ | `android/…/utils/SsaCueNormalizer.kt` | 93 | Clears the `line`/`position` Media3's `SsaParser` fills in on cues the ASS author never placed, so `bottomOffset` and cue stacking work on `.ass` the way they already do on `.srt`. The test is exact rather than heuristic: those values are a pure function of the cue's anchors, so matching both defaults for the bottom-centre anchors *is* "no positioning tag". Also drops events on a style the author made invisible (`Fontsize 0`, or a primary colour whose inverted alpha byte is `FF`) — that is where typesetters park working notes, and nothing downstream can carry "invisible". |
31
32
  | `android/…/utils/RoundedSubtitleBackground.kt` | 352 | Draws `subtitleStyle.backgroundRadius` as rounded boxes, which Media3 cannot do at all: it paints `backgroundColor` as a framework `BackgroundColorSpan` and `windowColor` with `canvas.drawRect`, neither takes a radius, and `SubtitlePainter`/`CanvasSubtitleOutput` are package-private final while `SubtitleView` is final. A second `SubtitleView` is inserted behind `PlayerView`'s own, fed from `Player.Listener.onCues`, drawing a `LineBackgroundSpan` per line. **Its `EDGE_TYPE_NONE` is load-bearing** — `SubtitlePainter` copies the cue text for an edge pass and draws both layouts when the edge type is outline/raised/depressed, which would paint the box twice and darken a semi-transparent colour. State hangs off the view's `tag`, never a map in the companion object; see the `find` KDoc for why even a `WeakHashMap` leaks here. |
32
33
  | `FORK.md`, `LICENSE` | — | This guide; MIT licence (upstream copyright preserved). |
33
34
  | `eslint.config.js` | 4 | Verbatim copy of `expo-module-scripts/templates/eslint.config.js`. **Recreate it if a sync drops it** — `expo-module configure` treats it as an *optional* template and only syncs it when it already exists, so without this file `npm run lint` dies with "ESLint couldn't find an eslint.config.js". |
@@ -0,0 +1,93 @@
1
+ package expo.modules.video.utils
2
+
3
+ import android.graphics.Color
4
+ import android.text.Spanned
5
+ import android.text.style.ForegroundColorSpan
6
+ import androidx.annotation.OptIn
7
+ import androidx.media3.common.text.Cue
8
+ import androidx.media3.common.util.UnstableApi
9
+ import kotlin.math.abs
10
+
11
+ /**
12
+ * Undoes two things Media3's `SsaParser` does to every ASS cue, so the rest of this package can
13
+ * treat an `.ass` track the way it treats every other format.
14
+ *
15
+ * ### Dialogue gets its position taken back off
16
+ *
17
+ * `SsaParser.createCue` ends with an `else` branch that runs for every cue *without* a `\pos` or
18
+ * `\move`: it calls `setPosition` and `setLine` with values derived purely from the cue's
19
+ * alignment. So an ordinary bottom-centre dialogue line — the overwhelming majority of a subtitle
20
+ * file — arrives carrying `line = 0.95` and `position = 0.5` even though the author placed nothing.
21
+ *
22
+ * Two features break on that. `SubtitlePainter` applies `bottomPaddingFraction` only to cues whose
23
+ * `line` is unset, so `SubtitleSource.bottomOffset` (the app's `subtitle_offset_percent`, used to
24
+ * lift softsubs clear of burnt-in hardsubs) silently stops moving anything. And
25
+ * [StackingSubtitleParser] treats a positioned cue as deliberately placed and passes it through
26
+ * unstacked, so two simultaneous speakers draw on top of each other.
27
+ *
28
+ * Recognising "the parser filled this in" is exact rather than a guess: the values are a pure
29
+ * function of the anchors, so a cue matching *both* defaults for the bottom-centre anchors is
30
+ * precisely a cue that had no positioning tag. Clearing them puts an ASS dialogue cue in the same
31
+ * shape as a SubRip one, which is the shape both features already work on.
32
+ *
33
+ * A cue the author did place — `\an8`, `\pos`, a style aligned anywhere but bottom-centre — fails
34
+ * the check and keeps everything. That is the signs-and-typesetting case, and it must not move.
35
+ *
36
+ * ### Cues the author made invisible are dropped
37
+ *
38
+ * Typesetters keep their working notes in the script on a style that renders nothing: JJK S3E3
39
+ * ships `Style: f0ld3r-invisible` with `Fontsize 0` and every colour `&HFF……`, holding 158 events
40
+ * of production notes ("--mahito", "#1", "check in frame 6212").
41
+ *
42
+ * libass honours both signals and shows nothing. Media3 turns the fully transparent primary colour
43
+ * into a `ForegroundColorSpan` with alpha 0 — and [SubtitleSpanFilter] then strips colour spans,
44
+ * because the app owns the subtitle colour. The note would come back in the viewer's own colour, at
45
+ * the viewer's own size, on screen. Dropping the cue here, before any of that, is the only place
46
+ * the alpha still means what the author meant.
47
+ */
48
+ @OptIn(UnstableApi::class)
49
+ internal object SsaCueNormalizer {
50
+ /** `SsaParser.DEFAULT_MARGIN`. Kept in sync by [sitsWhereTheParserPutIt]'s test. */
51
+ private const val DEFAULT_MARGIN = 0.05f
52
+
53
+ /** The values are computed, not parsed, so they compare exactly; this only guards float drift. */
54
+ private const val EPSILON = 0.0001f
55
+
56
+ fun normalize(cue: Cue): Cue? {
57
+ if (isInvisible(cue)) return null
58
+ if (!sitsWhereTheParserPutIt(cue)) return cue
59
+
60
+ return cue.buildUpon()
61
+ .setLine(Cue.DIMEN_UNSET, Cue.LINE_TYPE_FRACTION)
62
+ .setPosition(Cue.DIMEN_UNSET)
63
+ .build()
64
+ }
65
+
66
+ /**
67
+ * True when the cue's placement is exactly what `SsaParser` computes for the bottom-centre
68
+ * anchors, which is to say the author placed nothing.
69
+ */
70
+ private fun sitsWhereTheParserPutIt(cue: Cue): Boolean =
71
+ cue.lineType == Cue.LINE_TYPE_FRACTION &&
72
+ cue.lineAnchor == Cue.ANCHOR_TYPE_END &&
73
+ cue.positionAnchor == Cue.ANCHOR_TYPE_MIDDLE &&
74
+ abs(cue.line - (1f - DEFAULT_MARGIN)) < EPSILON &&
75
+ abs(cue.position - 0.5f) < EPSILON
76
+
77
+ /**
78
+ * Either of the two ways an ASS style says "draw nothing": a fully transparent primary colour, or
79
+ * a zero font size. Both have to cover the whole cue — a transparent run *inside* a line is a
80
+ * typesetting effect, not a hidden line.
81
+ */
82
+ private fun isInvisible(cue: Cue): Boolean {
83
+ val text = cue.text ?: return false
84
+ if (cue.textSize == 0f) return true
85
+ if (text !is Spanned || text.isEmpty()) return false
86
+
87
+ return text.getSpans(0, text.length, ForegroundColorSpan::class.java).any { span ->
88
+ Color.alpha(span.foregroundColor) == 0 &&
89
+ text.getSpanStart(span) == 0 &&
90
+ text.getSpanEnd(span) == text.length
91
+ }
92
+ }
93
+ }
@@ -24,8 +24,8 @@ import androidx.media3.extractor.text.subrip.SubripParser
24
24
  internal const val HELD_SLOT_PLACEHOLDER = '\u200B'
25
25
 
26
26
  /**
27
- * A [SubtitleParser] for SubRip (`.srt`) that lays out simultaneous cues as a single stacked block
28
- * instead of drawing them on top of each other.
27
+ * A [SubtitleParser] that lays out simultaneous unpositioned cues as a single stacked block instead
28
+ * of drawing them on top of each other.
29
29
  *
30
30
  * ### Why this is needed
31
31
  *
@@ -53,14 +53,25 @@ internal const val HELD_SLOT_PLACEHOLDER = '\u200B'
53
53
  * A cue also keeps the same line for its whole time on screen. Because the block is anchored at its
54
54
  * bottom, a cue would otherwise drop down a line the moment the cue *below* it ended — moving the
55
55
  * text the viewer is reading at that moment. The vacated line is therefore held as blank height
56
- * until a later cue reclaims it; see [StackingSubripParser.updateSlots].
56
+ * until a later cue reclaims it; see [StackingSubtitleParser.updateSlots].
57
57
  *
58
- * Cues that *do* carry explicit positioning (SubRip's `{\anN}` tags) are passed through untouched —
59
- * they were placed deliberately and must not be merged.
58
+ * Cues that *do* carry explicit positioning (SubRip's `{\anN}` tags, an ASS `\pos` or `\an`) are
59
+ * passed through untouched — they were placed deliberately and must not be merged.
60
+ *
61
+ * ASS reaches this through [SsaCueNormalizer] as the `prepare` step, which is what makes the
62
+ * paragraph above true for it: Media3 positions every SSA cue, so without that step every one of
63
+ * them would take the passthrough branch. See [StackingSsaParser].
60
64
  */
61
65
  @OptIn(UnstableApi::class)
62
- class StackingSubripParser : SubtitleParser {
63
- private val delegate = SubripParser()
66
+ open class StackingSubtitleParser(
67
+ private val delegate: SubtitleParser,
68
+ /**
69
+ * Applied to every cue as it arrives, before any overlap is worked out. Returning null drops the
70
+ * cue. [SsaCueNormalizer] uses this to hand back the cues an ASS author never placed, so they
71
+ * reach the slot layout below instead of the passthrough branch.
72
+ */
73
+ private val prepare: (Cue) -> Cue? = { it },
74
+ ) : SubtitleParser {
64
75
 
65
76
  // Every emitted segment is a complete description of what should be on screen for its interval,
66
77
  // so segments replace each other rather than being merged by the renderer.
@@ -84,7 +95,8 @@ class StackingSubripParser : SubtitleParser {
84
95
  val startUs = cuesWithTiming.startTimeUs
85
96
  val endUs = resolveEndUs(cuesWithTiming)
86
97
  for (cue in cuesWithTiming.cues) {
87
- entries.add(Entry(cue, startUs, endUs, entries.size))
98
+ val prepared = prepare(cue) ?: continue
99
+ entries.add(Entry(prepared, startUs, endUs, entries.size))
88
100
  }
89
101
  }
90
102
 
@@ -274,17 +286,26 @@ class StackingSubripParser : SubtitleParser {
274
286
  }
275
287
  }
276
288
 
289
+ /** SubRip, which Media3 already leaves unpositioned. */
290
+ @OptIn(UnstableApi::class)
291
+ class StackingSubripParser : StackingSubtitleParser(SubripParser())
292
+
277
293
  /**
278
- * Routes SubRip through [StackingSubripParser] so simultaneous cues stack, delegates every other
279
- * subtitle format to Media3's default handling, and wraps the result in [FilteringSubtitleParser] so
280
- * the appearance a track asks for never overrides the app's own subtitle style.
294
+ * ASS and SSA, via [SsaCueNormalizer].
281
295
  *
282
- * SSA and ASS are deliberately NOT stacked. `SsaParser` gives every cue both a `line` and a
283
- * `position`, so [StackingSubripParser.hasExplicitPosition] would send all of them down the
284
- * passthrough branch anyway, and its merge deliberately leaves `line` unset, which would fight
285
- * `\an` and `\pos`. The consequence is real and known: Media3 does no collision avoidance between
286
- * positioned cues, so two simultaneous `\an2` dialogue lines overlap on Android where libass would
287
- * stack them.
296
+ * The delegate is built by the caller rather than constructed here because an SSA track muxed into
297
+ * a container carries its `[V4+ Styles]` header in the format's initialization data, and only
298
+ * `DefaultSubtitleParserFactory` knows to pass it on. A parser built without it resolves no styles.
299
+ */
300
+ @OptIn(UnstableApi::class)
301
+ class StackingSsaParser(delegate: SubtitleParser) :
302
+ StackingSubtitleParser(delegate, SsaCueNormalizer::normalize)
303
+
304
+ /**
305
+ * Routes SubRip and ASS/SSA through the stacking parsers so simultaneous cues stack, delegates every
306
+ * other subtitle format to Media3's default handling, and wraps the result in
307
+ * [FilteringSubtitleParser] so the appearance a track asks for never overrides the app's own
308
+ * subtitle style — except for cues the track positions itself, which keep theirs.
288
309
  */
289
310
  @OptIn(UnstableApi::class)
290
311
  class ExpoVideoSubtitleParserFactory : SubtitleParser.Factory {
@@ -294,17 +315,28 @@ class ExpoVideoSubtitleParserFactory : SubtitleParser.Factory {
294
315
  format.isSubrip() || defaultFactory.supportsFormat(format)
295
316
 
296
317
  override fun getCueReplacementBehavior(format: Format): Int =
297
- if (format.isSubrip()) {
318
+ if (format.isSubrip() || format.isSsa()) {
319
+ // Both stacking parsers emit whole-screen segments, whatever the delegate's own behaviour is.
298
320
  Format.CUE_REPLACEMENT_BEHAVIOR_REPLACE
299
321
  } else {
300
322
  defaultFactory.getCueReplacementBehavior(format)
301
323
  }
302
324
 
303
- override fun create(format: Format): SubtitleParser =
304
- FilteringSubtitleParser(
305
- if (format.isSubrip()) StackingSubripParser() else defaultFactory.create(format)
325
+ override fun create(format: Format): SubtitleParser = when {
326
+ format.isSubrip() -> FilteringSubtitleParser(StackingSubripParser())
327
+ // Only ASS gets the positioned-cue carve-out. It is the one format where a positioned cue is
328
+ // reliably a sign — typeset to sit on the artwork, in colours chosen to match it — rather than
329
+ // dialogue that happens to carry a cue setting.
330
+ format.isSsa() -> FilteringSubtitleParser(
331
+ StackingSsaParser(defaultFactory.create(format)),
332
+ keepAppearanceOfPositionedCues = true,
306
333
  )
334
+ else -> FilteringSubtitleParser(defaultFactory.create(format))
335
+ }
307
336
 
308
337
  private fun Format.isSubrip(): Boolean =
309
338
  MimeTypes.APPLICATION_SUBRIP.equals(sampleMimeType, ignoreCase = true)
339
+
340
+ private fun Format.isSsa(): Boolean =
341
+ MimeTypes.TEXT_SSA.equals(sampleMimeType, ignoreCase = true)
310
342
  }
@@ -56,7 +56,18 @@ internal object SubtitleSpanFilter {
56
56
  else -> false
57
57
  }
58
58
 
59
- fun filter(cue: Cue): Cue {
59
+ /**
60
+ * @param keepPositionedCues when true, a cue the track placed itself keeps every span it came
61
+ * with. Signs are typeset onto the artwork in colours chosen to sit on it — the dark lettering
62
+ * on the white placards in Jujutsu Kaisen S3E3 is the case that forced this — so repainting
63
+ * them in the viewer's dialogue colour makes them unreadable rather than consistent. Dialogue
64
+ * is unpositioned and still loses its appearance, which is the setting the viewer actually
65
+ * controls. Only ASS passes true: it is the one format where positioning reliably means
66
+ * "sign", rather than a cue setting on ordinary dialogue.
67
+ */
68
+ fun filter(cue: Cue, keepPositionedCues: Boolean = false): Cue {
69
+ if (keepPositionedCues && cue.isPositioned()) return cue
70
+
60
71
  val text = cue.text
61
72
  val hasSpans = text is Spanned
62
73
  val needsWork = hasSpans || cue.windowColorSet || cue.textSize != Cue.DIMEN_UNSET
@@ -95,18 +106,28 @@ internal object SubtitleSpanFilter {
95
106
 
96
107
  return builder.build()
97
108
  }
109
+
110
+ /**
111
+ * Whether the track placed this cue rather than letting the renderer put it at the bottom.
112
+ *
113
+ * For ASS this is only meaningful after [SsaCueNormalizer] has run: Media3 fills both fields in
114
+ * on every SSA cue, so before normalization every cue answers true.
115
+ */
116
+ private fun Cue.isPositioned(): Boolean =
117
+ line != Cue.DIMEN_UNSET || position != Cue.DIMEN_UNSET
98
118
  }
99
119
 
100
120
  /**
101
121
  * Applies [SubtitleSpanFilter] to every cue a delegate parser emits.
102
122
  *
103
123
  * Wrapping is deliberately the outermost layer so it also covers the merged
104
- * block [StackingSubripParser] produces, and so every format gets the same
124
+ * block [StackingSubtitleParser] produces, and so every format gets the same
105
125
  * policy for free.
106
126
  */
107
127
  @OptIn(UnstableApi::class)
108
128
  internal class FilteringSubtitleParser(
109
- private val delegate: SubtitleParser
129
+ private val delegate: SubtitleParser,
130
+ private val keepAppearanceOfPositionedCues: Boolean = false,
110
131
  ) : SubtitleParser {
111
132
  // Forwarded verbatim. Getting this wrong silently changes whether consecutive
112
133
  // CuesWithTiming replace or merge: SsaParser is MERGE, StackingSubripParser
@@ -125,7 +146,7 @@ internal class FilteringSubtitleParser(
125
146
  delegate.parse(data, offset, length, outputOptions) { group ->
126
147
  output.accept(
127
148
  CuesWithTiming(
128
- group.cues.map(SubtitleSpanFilter::filter),
149
+ group.cues.map { SubtitleSpanFilter.filter(it, keepAppearanceOfPositionedCues) },
129
150
  group.startTimeUs,
130
151
  group.durationUs
131
152
  )
@@ -27,6 +27,10 @@ internal extension SubtitleConverter {
27
27
  guard event.endMs > event.startMs else { continue }
28
28
 
29
29
  let style = script.styles[event.styleName]
30
+ // A style that draws nothing is the author hiding a working note in a script they still ship.
31
+ // WebVTT has no way to carry "invisible", so the note has to be dropped here or it is read.
32
+ if style?.invisible == true { continue }
33
+
30
34
  var rendered = renderAssText(
31
35
  event.text,
32
36
  defaultAlignment: style?.alignment ?? 2,
@@ -611,6 +615,8 @@ private struct AssScript {
611
615
  var alignment: Int
612
616
  var italic: Bool
613
617
  var underline: Bool
618
+ /// The author asked for this style to draw nothing. See `isInvisibleStyle`.
619
+ var invisible: Bool
614
620
  }
615
621
 
616
622
  struct Event {
@@ -678,7 +684,8 @@ private struct AssScript {
678
684
  ? (SubtitleConverter.legacyAlignment(rawAlignment) ?? 2)
679
685
  : ((1...9).contains(rawAlignment) ? rawAlignment : 2),
680
686
  italic: field("italic") == "-1",
681
- underline: field("underline") == "-1"
687
+ underline: field("underline") == "-1",
688
+ invisible: Self.isInvisibleStyle(fontSize: field("fontsize"), primaryColour: field("primarycolour"))
682
689
  )
683
690
  }
684
691
 
@@ -705,6 +712,28 @@ private struct AssScript {
705
712
  if sawPlayResX && !sawPlayResY { playResY = (playResX * 3 / 4).rounded() }
706
713
  }
707
714
 
715
+ /// Whether a style renders nothing at all, which is how a typesetter parks working notes in a
716
+ /// script they still have to ship.
717
+ ///
718
+ /// Jujutsu Kaisen S3E3 carries `Style: f0ld3r-invisible` — `Fontsize 0`, every colour `&HFF……` —
719
+ /// holding 158 events of production notes ("--mahito", "#1", "check in frame 6212"). libass
720
+ /// honours both signals and draws nothing. Nothing downstream of this converter can: WebVTT
721
+ /// carries no per-cue opacity, and the app owns the subtitle colour anyway, so a note that
722
+ /// reaches a cue is a note the viewer reads.
723
+ ///
724
+ /// In ASS the alpha byte is inverted — `&HAABBGGRR` with `FF` meaning fully transparent — so the
725
+ /// test is the two leading hex digits after `&H`, and a six-digit colour has no alpha byte and is
726
+ /// therefore opaque.
727
+ private static func isInvisibleStyle(fontSize: String?, primaryColour: String?) -> Bool {
728
+ if let fontSize, let size = Double(fontSize), size <= 0 { return true }
729
+
730
+ guard var colour = primaryColour?.trimmingCharacters(in: .whitespaces) else { return false }
731
+ if colour.hasPrefix("&H") || colour.hasPrefix("&h") { colour = String(colour.dropFirst(2)) }
732
+ colour = colour.hasSuffix("&") ? String(colour.dropLast()) : colour
733
+ guard colour.count == 8, let alpha = UInt8(colour.prefix(2), radix: 16) else { return false }
734
+ return alpha == 0xFF
735
+ }
736
+
708
737
  private static func parseEvent(_ value: String, fields: [String]) -> Event? {
709
738
  // Fall back to the canonical ASS order when the section had no Format: line.
710
739
  let names = fields.isEmpty
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-video-subtitle",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",