expo-video-subtitle 0.8.0 → 0.8.1
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,41 @@ 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.1
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **A plain `.vtt` track sat flush against the bottom edge and ignored `bottomOffset` (Android).**
|
|
15
|
+
`.srt` and `.ass` tracks in the same player were fine.
|
|
16
|
+
|
|
17
|
+
Media3's `WebvttCueParser` leaves a cue with no `line:` setting unpositioned, but that is not what
|
|
18
|
+
reaches the screen. `WebvttParser.parse` hands its cues through `WebvttSubtitle.getCues`, which
|
|
19
|
+
runs the WebVTT spec's snap-to-lines stacking on every cue, unconditionally — a lone cue comes out
|
|
20
|
+
as `line = -1, LINE_TYPE_NUMBER`. `SubtitlePainter` then takes its number-line branch, where `-1`
|
|
21
|
+
is `parentBottom - textHeight` and `bottomPaddingFraction` is never read; the painter only consults
|
|
22
|
+
it when `line` is unset. So a WebVTT cue landed `parentHeight × padding` lower than the identical
|
|
23
|
+
SubRip cue — 54px on a 1080-tall frame at the app's 5% — and no offset could move it.
|
|
24
|
+
|
|
25
|
+
SubRip never had this because `SubripParser` leaves `line` alone. ASS had it and was already fixed:
|
|
26
|
+
`SsaCueNormalizer` strips the placement `SsaParser` stamps on. WebVTT was the one format with the
|
|
27
|
+
stamp and no normalizer. It now goes through `StackingSubtitleParser` behind `WebvttCueNormalizer`,
|
|
28
|
+
which clears a negative line number and the derived centre `position`, so a plain cue takes the
|
|
29
|
+
app's padding and simultaneous cues stack — exactly as `.srt` does.
|
|
30
|
+
|
|
31
|
+
Verified against the 1.9.0 artefacts the app ships, from source and from decompiled bytecode; the
|
|
32
|
+
logic is byte for byte identical to 1.8.0, so this was never a version question.
|
|
33
|
+
|
|
34
|
+
One honest limit: after `getCues` has run, a parser-stacked `line = -1` is indistinguishable from
|
|
35
|
+
an author's explicit `line:-1`, so both are reset. `line:-1` means "the last line", and "the last
|
|
36
|
+
line, lifted by the app's padding" is where every other format's bottom cue already goes;
|
|
37
|
+
percentages and non-negative line numbers are untouched.
|
|
38
|
+
|
|
39
|
+
### Why it surfaced now
|
|
40
|
+
|
|
41
|
+
Nothing changed in this path for a long time. Until `SUBTITLE_PRESERVE_FORMAT` was switched on, every
|
|
42
|
+
anikoto sidecar was converted to SubRip before it reached the app, so the WebVTT path was never
|
|
43
|
+
exercised. Storing the `.vtt` as-is is what put the first real WebVTT track in front of it.
|
|
44
|
+
|
|
10
45
|
## 0.8.0
|
|
11
46
|
|
|
12
47
|
### Added
|
package/FORK.md
CHANGED
|
@@ -29,6 +29,7 @@ Measured against the published `expo-video@56.1.4` tarball. `+`/`-` are added/re
|
|
|
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
30
|
| `android/…/utils/StackingSubtitleParser.kt` | 342 | `SubtitleParser` that segments the cue timeline and merges simultaneous **unpositioned** cues into one stacked block, plus the `SubtitleParser.Factory` that routes SubRip and ASS/SSA to it and wraps every parser in `FilteringSubtitleParser`. ASS gets there through `SsaCueNormalizer` as the `prepare` step — without it `SsaParser` sets both `line` and `position` on every cue and they would all take the passthrough branch. Assigns each cue a vertical slot it keeps for its whole time on screen — a finished cue's line is held blank (`\u200B`) so the cues above it do not drop. Merges through a `SpannableStringBuilder` so cue spans survive. |
|
|
31
31
|
| `android/…/utils/SsaCueNormalizer.kt` | 93 | Clears the `line`/`position` Media3's `SsaParser` fills in on cues the ASS author never placed, so `bottomOffset` and cue stacking work on `.ass` the way they already do on `.srt`. The test is exact rather than heuristic: those values are a pure function of the cue's anchors, so matching both defaults for the bottom-centre anchors *is* "no positioning tag". Also drops events on a style the author made invisible (`Fontsize 0`, or a primary colour whose inverted alpha byte is `FF`) — that is where typesetters park working notes, and nothing downstream can carry "invisible". |
|
|
32
|
+
| `android/…/utils/WebvttCueNormalizer.kt` | 61 | Clears the `line = -1 - i` that Media3's `WebvttSubtitle.getCues` stamps on every unpositioned WebVTT cue, plus the derived centre `position`, so `.vtt` takes the app's bottom padding and stacks like `.srt`. **Not exact the way the SSA one is**: once `getCues` has run, a parser-stacked `-1` and an author's `line:-1` are the same cue, so both are reset — a defensible reading, since `line:-1` means "the last line". Percentages and non-negative numbers are untouched. |
|
|
32
33
|
| `android/…/utils/AssSignParser.kt` | 505 | Splits an ASS script into the typeset events this package draws and a copy of the script with those lines removed for Media3's parser. One pass, no matching step, so an event is in exactly one of the two outputs and cannot be drawn twice. Pure Kotlin with no Android imports, which is what makes it unit-testable on the JVM. Reads `\pos`, `\move`, `\org`, `\an`, `\frz`, `\frx`, `\fry`, `\fscx/y`, `\fs`, `\c`, `\alpha`, `\b`, `\i`, `\u` and the style columns behind them. The tag scanner takes the longest name first — `\c` must not swallow `\clip`, `\b` must not swallow `\bord`, `\blur` or `\be` — and reads a leading digit as part of the name for `\1c` and `\4a`. |
|
|
33
34
|
| `android/…/utils/AssSignOverlay.kt` | 330 | Draws those signs on a canvas in front of the captions, with `Matrix` for rotation and scale and `Camera` for the two 3-D axes. Drives itself from the player's clock on a `Choreographer` callback, because signs do not ride the cue stream and nothing else would tell it when one starts; 40 ms events need frame granularity anyway. **Media3 cannot do this**: `SubtitlePainter` has no rotate, skew, Matrix or Camera call, and `Cue` has no rotation field. Sits in the same parent as `SubtitleView` — the content frame, sized to the video, which is why `\pos` survives letterboxing. Lays text out on change rather than per frame, because frame-by-frame typesetting changes the on-screen set every 40 ms. |
|
|
34
35
|
| `android/…/utils/AssSignStore.kt` | 61 | Hands parsed signs from the parser to the overlay, keyed by the track's `Format.id`. **Not** the cue stream: `CueEncoder` marshals every cue through `Parcel`, and a custom span that is neither a framework `ParcelableSpan` nor one of Media3's three `CustomSpanBundler` types is dropped without a word — which is how 0.7.0 shipped with signs that parsed correctly and never appeared. Staying out of the cue stream also keeps 12,616 sign events from putting ~26,000 boundaries through the O(n²) timeline pass. |
|
|
@@ -364,8 +364,8 @@ class StackingSsaParser(
|
|
|
364
364
|
}
|
|
365
365
|
|
|
366
366
|
/**
|
|
367
|
-
* Routes SubRip
|
|
368
|
-
* other subtitle format to Media3's default handling, and wraps the result in
|
|
367
|
+
* Routes SubRip, ASS/SSA and WebVTT through the stacking parsers so simultaneous cues stack and the
|
|
368
|
+
* app's bottom padding applies, delegates every other subtitle format to Media3's default handling, and wraps the result in
|
|
369
369
|
* [FilteringSubtitleParser] so the appearance a track asks for never overrides the app's own
|
|
370
370
|
* subtitle style — except for cues the track positions itself, which keep theirs.
|
|
371
371
|
*/
|
|
@@ -377,8 +377,8 @@ class ExpoVideoSubtitleParserFactory : SubtitleParser.Factory {
|
|
|
377
377
|
format.isSubrip() || defaultFactory.supportsFormat(format)
|
|
378
378
|
|
|
379
379
|
override fun getCueReplacementBehavior(format: Format): Int =
|
|
380
|
-
if (format.isSubrip() || format.isSsa()) {
|
|
381
|
-
//
|
|
380
|
+
if (format.isSubrip() || format.isSsa() || format.isWebvtt()) {
|
|
381
|
+
// Every stacking parser emits whole-screen segments, whatever the delegate's own behaviour is.
|
|
382
382
|
Format.CUE_REPLACEMENT_BEHAVIOR_REPLACE
|
|
383
383
|
} else {
|
|
384
384
|
defaultFactory.getCueReplacementBehavior(format)
|
|
@@ -393,6 +393,13 @@ class ExpoVideoSubtitleParserFactory : SubtitleParser.Factory {
|
|
|
393
393
|
StackingSsaParser(defaultFactory.create(format), format.id),
|
|
394
394
|
keepAppearanceOfPositionedCues = true,
|
|
395
395
|
)
|
|
396
|
+
// WebVTT gets the stacking parser too, with the placement Media3 stamps on every unpositioned
|
|
397
|
+
// cue taken back off first. Without that, a plain `.vtt` sat flush against the bottom edge and
|
|
398
|
+
// ignored `bottomOffset` entirely — see WebvttCueNormalizer. Not the sign carve-out: a
|
|
399
|
+
// positioned WebVTT cue is a cue setting on ordinary dialogue, not typesetting.
|
|
400
|
+
format.isWebvtt() -> FilteringSubtitleParser(
|
|
401
|
+
StackingSubtitleParser(defaultFactory.create(format), WebvttCueNormalizer::normalize)
|
|
402
|
+
)
|
|
396
403
|
else -> FilteringSubtitleParser(defaultFactory.create(format))
|
|
397
404
|
}
|
|
398
405
|
|
|
@@ -401,4 +408,7 @@ class ExpoVideoSubtitleParserFactory : SubtitleParser.Factory {
|
|
|
401
408
|
|
|
402
409
|
private fun Format.isSsa(): Boolean =
|
|
403
410
|
MimeTypes.TEXT_SSA.equals(sampleMimeType, ignoreCase = true)
|
|
411
|
+
|
|
412
|
+
private fun Format.isWebvtt(): Boolean =
|
|
413
|
+
MimeTypes.TEXT_VTT.equals(sampleMimeType, ignoreCase = true)
|
|
404
414
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
package expo.modules.video.utils
|
|
2
|
+
|
|
3
|
+
import androidx.annotation.OptIn
|
|
4
|
+
import androidx.media3.common.text.Cue
|
|
5
|
+
import androidx.media3.common.util.UnstableApi
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Undoes the placement Media3's WebVTT path stamps on every cue that had none, so a `.vtt` track
|
|
9
|
+
* behaves like a `.srt` one: it takes the app's bottom padding, and simultaneous cues stack.
|
|
10
|
+
*
|
|
11
|
+
* ### What Media3 does
|
|
12
|
+
*
|
|
13
|
+
* `WebvttCueParser` leaves a cue with no `line:` setting at `line = DIMEN_UNSET`. That is not what
|
|
14
|
+
* reaches the screen. `WebvttParser.parse` wraps its cues in a `WebvttSubtitle` and hands them
|
|
15
|
+
* through `LegacySubtitleUtil`, and `WebvttSubtitle.getCues` runs the spec's snap-to-lines stacking
|
|
16
|
+
* on every query, unconditionally:
|
|
17
|
+
*
|
|
18
|
+
* ```java
|
|
19
|
+
* // Steps 4 - 10 of https://www.w3.org/TR/webvtt1/#cue-computed-line
|
|
20
|
+
* currentCues.add(cue.buildUpon().setLine((float) (-1 - i), Cue.LINE_TYPE_NUMBER).build());
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* A lone cue gets `line = -1`. `SubtitlePainter` then takes its `LINE_TYPE_NUMBER` branch, where
|
|
24
|
+
* `line = -1` is `parentBottom - textHeight` — flush against the bottom edge — and
|
|
25
|
+
* `bottomPaddingFraction` is never read; the painter only consults it when `line` is unset. So a
|
|
26
|
+
* plain WebVTT track sits `parentHeight × padding` lower than the identical SubRip track, and
|
|
27
|
+
* `SubtitleSource.bottomOffset` has no effect on it at all. Verified against the 1.9.0 artefacts the
|
|
28
|
+
* app ships, byte for byte identical to 1.8.0.
|
|
29
|
+
*
|
|
30
|
+
* `position` gets the same treatment: with no setting, `derivePosition(CENTER)` yields `0.5` with
|
|
31
|
+
* `ANCHOR_TYPE_MIDDLE`. That is harmless for the painter but it makes [StackingSubtitleParser] read
|
|
32
|
+
* the cue as author-placed and pass it through unstacked.
|
|
33
|
+
*
|
|
34
|
+
* ### Why this cannot be exact, and why that is acceptable
|
|
35
|
+
*
|
|
36
|
+
* [SsaCueNormalizer] can recognise "the parser filled this in" precisely, because Media3's SSA
|
|
37
|
+
* defaults are a pure function of the anchors. WebVTT's are not: once `getCues` has run, a cue the
|
|
38
|
+
* parser stacked to `line = -1` is identical to one whose author wrote `line:-1`. Both are reset
|
|
39
|
+
* here. That is a defensible reading rather than a loss — `line:-1` means "the last line", and
|
|
40
|
+
* "the last line, lifted by the app's padding" is where every other format's bottom cue already
|
|
41
|
+
* goes. An author who wants an exact placement writes a percentage, and percentages are untouched.
|
|
42
|
+
*
|
|
43
|
+
* Only negative line *numbers* qualify. A fraction (`line:80%`), a non-negative number (`line:3`,
|
|
44
|
+
* counted from the top) or an explicit `position` off centre is a placement someone chose.
|
|
45
|
+
*/
|
|
46
|
+
@OptIn(UnstableApi::class)
|
|
47
|
+
internal object WebvttCueNormalizer {
|
|
48
|
+
private const val DEFAULT_POSITION = 0.5f
|
|
49
|
+
private const val EPSILON = 0.0001f
|
|
50
|
+
|
|
51
|
+
fun normalize(cue: Cue): Cue {
|
|
52
|
+
val stackedLine = cue.lineType == Cue.LINE_TYPE_NUMBER && cue.line != Cue.DIMEN_UNSET && cue.line < 0f
|
|
53
|
+
val derivedPosition = cue.positionAnchor == Cue.ANCHOR_TYPE_MIDDLE &&
|
|
54
|
+
cue.position != Cue.DIMEN_UNSET &&
|
|
55
|
+
kotlin.math.abs(cue.position - DEFAULT_POSITION) < EPSILON
|
|
56
|
+
|
|
57
|
+
if (!stackedLine && !derivedPosition) return cue
|
|
58
|
+
|
|
59
|
+
val builder = cue.buildUpon()
|
|
60
|
+
if (stackedLine) builder.setLine(Cue.DIMEN_UNSET, Cue.LINE_TYPE_FRACTION)
|
|
61
|
+
if (derivedPosition) builder.setPosition(Cue.DIMEN_UNSET)
|
|
62
|
+
return builder.build()
|
|
63
|
+
}
|
|
64
|
+
}
|
package/package.json
CHANGED