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 +40 -0
- package/FORK.md +2 -1
- package/android/src/main/java/expo/modules/video/utils/SsaCueNormalizer.kt +93 -0
- package/android/src/main/java/expo/modules/video/utils/StackingSubtitleParser.kt +53 -21
- package/android/src/main/java/expo/modules/video/utils/SubtitleSpanFilter.kt +25 -4
- package/ios/SubtitleConverterText.swift +30 -1
- package/package.json +1 -1
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` |
|
|
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]
|
|
28
|
-
*
|
|
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 [
|
|
56
|
+
* until a later cue reclaims it; see [StackingSubtitleParser.updateSlots].
|
|
57
57
|
*
|
|
58
|
-
* Cues that *do* carry explicit positioning (SubRip's `{\anN}` tags
|
|
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
|
|
63
|
-
private val delegate
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|
|
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