@banou/media-player 0.8.21 → 0.10.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/README.md +69 -19
- package/dist/engine/index.js +1 -1
- package/dist/engine/picture-in-picture.d.ts +9 -2
- package/dist/engine/playback.d.ts +8 -1
- package/dist/engine/subtitles.d.ts +16 -5
- package/dist/engine/thumbnails.d.ts +11 -0
- package/dist/{engine-B8CKGIng.js → engine-YeMWShCv.js} +243 -182
- package/dist/index.js +235 -205
- package/dist/react/components/chrome.d.ts +2 -2
- package/dist/react/components/overlay.d.ts +2 -2
- package/dist/react/hooks/use-picture-in-picture.d.ts +6 -1
- package/dist/react/hooks/use-playback.d.ts +1 -1
- package/dist/react/hooks/use-thumbnails.d.ts +12 -1
- package/dist/react/player.d.ts +1 -0
- package/dist/react/source-feature.d.ts +22 -0
- package/package.json +3 -4
- package/src/lib/engine/picture-in-picture.ts +24 -9
- package/src/lib/engine/playback.ts +10 -3
- package/src/lib/engine/subtitles.ts +213 -61
- package/src/lib/engine/thumbnails.ts +80 -21
- package/src/lib/react/components/chrome.tsx +16 -3
- package/src/lib/react/components/overlay.tsx +27 -8
- package/src/lib/react/components/progress-bar.tsx +17 -4
- package/src/lib/react/hooks/use-picture-in-picture.ts +10 -6
- package/src/lib/react/hooks/use-playback.ts +4 -4
- package/src/lib/react/hooks/use-thumbnails.ts +19 -3
- package/src/lib/react/source-feature.ts +12 -0
- package/src/lib/react/video-player.tsx +8 -6
|
@@ -3,12 +3,12 @@ export type ChromeProps = {
|
|
|
3
3
|
ref?: Ref<HTMLDivElement> | ((element: HTMLDivElement | null) => void);
|
|
4
4
|
/** Absent means render no video element: the media belongs to someone else and arrives as children. */
|
|
5
5
|
onVideoRef?: (element: HTMLVideoElement | null) => void;
|
|
6
|
-
|
|
6
|
+
onSubtitleRef: (element: HTMLDivElement | null) => void;
|
|
7
7
|
/** The app's own content, over the video and outside the click-to-pause region, unlike `children`. */
|
|
8
8
|
overlay?: ReactNode;
|
|
9
9
|
/** False draws no control bar at all, leaving the picture, the title and the overlay. */
|
|
10
10
|
controls?: boolean;
|
|
11
11
|
children?: ReactNode;
|
|
12
12
|
};
|
|
13
|
-
export declare const Chrome: ({ ref, onVideoRef,
|
|
13
|
+
export declare const Chrome: ({ ref, onVideoRef, onSubtitleRef, overlay, controls, children }: ChromeProps) => import("@emotion/react/jsx-runtime").JSX.Element;
|
|
14
14
|
export default Chrome;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const Overlay: ({
|
|
2
|
-
|
|
1
|
+
export declare const Overlay: ({ onSubtitleRef }: {
|
|
2
|
+
onSubtitleRef: (element: HTMLDivElement | null) => void;
|
|
3
3
|
}) => import("@emotion/react/jsx-runtime").JSX.Element;
|
|
4
4
|
export default Overlay;
|
|
@@ -13,5 +13,10 @@ export type PictureInPicture = {
|
|
|
13
13
|
* hidden mirror. Where it does not, and the engine is Gecko, the same composite becomes the picture
|
|
14
14
|
* in the page so that the BROWSER'S own picture in picture control carries the subtitles with it,
|
|
15
15
|
* which it otherwise cannot: it takes a video element, and the subtitles live on a canvas above one.
|
|
16
|
+
*
|
|
17
|
+
* The second argument is the subtitle LAYER, not that canvas. From jassub 2 the canvas belongs to the
|
|
18
|
+
* renderer and is replaced on every pipeline rebuild, so only the layer lives long enough to hold.
|
|
16
19
|
*/
|
|
17
|
-
export declare const usePictureInPicture: (video: HTMLVideoElement | null,
|
|
20
|
+
export declare const usePictureInPicture: (video: HTMLVideoElement | null,
|
|
21
|
+
/** The subtitle layer, not the canvas: see `PictureInPictureOptions.subtitles`. */
|
|
22
|
+
subtitles: HTMLElement | null) => PictureInPicture;
|
|
@@ -3,6 +3,6 @@ import type { MediaPlayerLocalOptions } from '../video-player';
|
|
|
3
3
|
* Owns the engine for the life of a source: start, teardown, and every piece of state the pipeline
|
|
4
4
|
* discovers. Nothing is mirrored in React state, so the store is the only place any of it lives.
|
|
5
5
|
*/
|
|
6
|
-
export declare const usePlayback: (video: HTMLVideoElement | null,
|
|
6
|
+
export declare const usePlayback: (video: HTMLVideoElement | null, subtitles: HTMLElement | null,
|
|
7
7
|
/** null when the media is remote: there are no bytes, so there is no pipeline to run. */
|
|
8
8
|
options: MediaPlayerLocalOptions | null) => void;
|
|
@@ -9,4 +9,15 @@ export type UseSeekThumbnailsOptions = {
|
|
|
9
9
|
/** When omitted the whole file is treated as readable, which is the case for a local file. */
|
|
10
10
|
downloadedRanges?: DownloadedRange[];
|
|
11
11
|
};
|
|
12
|
-
export
|
|
12
|
+
export type SeekThumbnails = {
|
|
13
|
+
thumbnails: ThumbnailImage[];
|
|
14
|
+
/**
|
|
15
|
+
* Where the viewer is pointing on the seekbar, so that preview is decoded next.
|
|
16
|
+
*
|
|
17
|
+
* Stable for the life of the hook, so it can be published to the store once and called from a
|
|
18
|
+
* pointermove without re-rendering anything. A no-op until the generator boots, and on a source
|
|
19
|
+
* that brings its own storyboard.
|
|
20
|
+
*/
|
|
21
|
+
requestThumbnail: (time: number | undefined) => void;
|
|
22
|
+
};
|
|
23
|
+
export declare const useSeekThumbnails: ({ publicPath, workerUrl, length, read, downloadedRanges, }: UseSeekThumbnailsOptions) => SeekThumbnails;
|
package/dist/react/player.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export declare const Player: import("@videojs/react").CreatePlayerResult<import(
|
|
|
5
5
|
indexes: import("..").MediaIndex[];
|
|
6
6
|
thumbnails: import("..").ThumbnailImage[];
|
|
7
7
|
thumbnailAt?: (time: number) => import("..").ThumbnailImage | undefined;
|
|
8
|
+
requestThumbnail: (time: number | undefined) => void;
|
|
8
9
|
subtitleTracks: import("./source-feature").TrackChoice[];
|
|
9
10
|
selectedSubtitleTrack: string | number | undefined;
|
|
10
11
|
selectSubtitleTrack: (id: string | number | undefined) => void | Promise<void>;
|
|
@@ -76,6 +76,17 @@ export type SourceState = {
|
|
|
76
76
|
* Falls back to scanning `thumbnails` when absent, which is what the engine's generator fills.
|
|
77
77
|
*/
|
|
78
78
|
thumbnailAt?: (time: number) => ThumbnailImage | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* Where the pointer is on the seekbar, so the preview under it is generated before the rest.
|
|
81
|
+
*
|
|
82
|
+
* Generation otherwise walks the file start to end, so pointing at the last third of a long video
|
|
83
|
+
* means waiting out everything before it. Called on every pointermove, which is why it is a
|
|
84
|
+
* callback on the store rather than a piece of state: a hover time held in the store would
|
|
85
|
+
* re-render every subscriber for each move.
|
|
86
|
+
*
|
|
87
|
+
* A no-op on a source that brings its own storyboard, and until the generator has booted.
|
|
88
|
+
*/
|
|
89
|
+
requestThumbnail: (time: number | undefined) => void;
|
|
79
90
|
/**
|
|
80
91
|
* Both selectors may answer with a promise, and the menu waits on it.
|
|
81
92
|
*
|
|
@@ -163,6 +174,17 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
|
|
|
163
174
|
* Falls back to scanning `thumbnails` when absent, which is what the engine's generator fills.
|
|
164
175
|
*/
|
|
165
176
|
thumbnailAt?: (time: number) => ThumbnailImage | undefined;
|
|
177
|
+
/**
|
|
178
|
+
* Where the pointer is on the seekbar, so the preview under it is generated before the rest.
|
|
179
|
+
*
|
|
180
|
+
* Generation otherwise walks the file start to end, so pointing at the last third of a long video
|
|
181
|
+
* means waiting out everything before it. Called on every pointermove, which is why it is a
|
|
182
|
+
* callback on the store rather than a piece of state: a hover time held in the store would
|
|
183
|
+
* re-render every subscriber for each move.
|
|
184
|
+
*
|
|
185
|
+
* A no-op on a source that brings its own storyboard, and until the generator has booted.
|
|
186
|
+
*/
|
|
187
|
+
requestThumbnail: (time: number | undefined) => void;
|
|
166
188
|
/**
|
|
167
189
|
* Both selectors may answer with a promise, and the menu waits on it.
|
|
168
190
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@banou/media-player",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "A video player for containers and codecs the browser cannot play natively, remuxed on the fly",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -34,9 +34,8 @@
|
|
|
34
34
|
"build-lib": "shx rm -rf dist/ && vp build -c vite.lib.config.ts && npm run types",
|
|
35
35
|
"prepublishOnly": "npm run build-lib",
|
|
36
36
|
"types": "tsc -p tsconfig.lib.json",
|
|
37
|
-
"copy-assets": "shx mkdir -p public && npm run copy-libav
|
|
37
|
+
"copy-assets": "shx mkdir -p public && npm run copy-libav",
|
|
38
38
|
"copy-libav": "shx cp node_modules/libav-wasm/build/worker.js public/libav-worker.js && shx cp node_modules/libav-wasm/build/libav.wasm public/libav.wasm && shx cp node_modules/libav-wasm/build/libav-jspi.wasm public/libav-jspi.wasm",
|
|
39
|
-
"copy-libass": "shx cp node_modules/jassub/dist/jassub-worker.js public/jassub-worker.js && shx cp node_modules/jassub/dist/jassub-worker.wasm public/jassub-worker.wasm && shx cp node_modules/jassub/dist/jassub-worker-modern.wasm public/jassub-worker-modern.wasm && shx cp node_modules/jassub/dist/default.woff2 public/default.woff2",
|
|
40
39
|
"test": "npm run fixtures && vp test run",
|
|
41
40
|
"fixtures": "node scripts/fixture.mjs"
|
|
42
41
|
},
|
|
@@ -45,7 +44,7 @@
|
|
|
45
44
|
"@videojs/core": "10.0.0-beta.26",
|
|
46
45
|
"@videojs/react": "10.0.0-beta.26",
|
|
47
46
|
"ass-compiler": "^0.1.16",
|
|
48
|
-
"jassub": "^
|
|
47
|
+
"jassub": "^2.5.16",
|
|
49
48
|
"libav-wasm": "^0.8.4",
|
|
50
49
|
"osra": "^0.6.12",
|
|
51
50
|
"react-feather": "^2.0.10",
|
|
@@ -27,8 +27,15 @@ export type PictureInPictureMode = 'window' | 'burn-in'
|
|
|
27
27
|
|
|
28
28
|
export type PictureInPictureOptions = {
|
|
29
29
|
video: HTMLVideoElement
|
|
30
|
-
/**
|
|
31
|
-
|
|
30
|
+
/**
|
|
31
|
+
* The subtitle LAYER, not the canvas inside it.
|
|
32
|
+
*
|
|
33
|
+
* From jassub 2 the canvas is created and removed by the subtitle renderer, one per pipeline
|
|
34
|
+
* build, so an element captured here would stop being the one being painted the moment the audio
|
|
35
|
+
* track changed. The layer is what React owns for the life of the player, so hiding and restoring
|
|
36
|
+
* it stays correct across a rebuild, and the canvas is looked up per frame.
|
|
37
|
+
*/
|
|
38
|
+
subtitles: HTMLElement
|
|
32
39
|
maxWidth?: number
|
|
33
40
|
/** Where the mirror is mounted. It must be in the document. Defaults to the video's parent. */
|
|
34
41
|
container?: HTMLElement
|
|
@@ -87,7 +94,7 @@ export const pictureInPictureMode = (): PictureInPictureMode | null => {
|
|
|
87
94
|
|
|
88
95
|
/** Takes over the document's Media Session play/pause handlers while a session is open. */
|
|
89
96
|
export const createPictureInPicture = (options: PictureInPictureOptions): PictureInPictureController => {
|
|
90
|
-
const { video,
|
|
97
|
+
const { video, subtitles } = options
|
|
91
98
|
const mode = options.mode ?? pictureInPictureMode() ?? 'window'
|
|
92
99
|
const maxWidth = options.maxWidth ?? (mode === 'burn-in' ? BURN_IN_MAX_WIDTH : DEFAULT_MAX_WIDTH)
|
|
93
100
|
|
|
@@ -112,9 +119,12 @@ export const createPictureInPicture = (options: PictureInPictureOptions): Pictur
|
|
|
112
119
|
|
|
113
120
|
const draw = (composite: HTMLCanvasElement, context: CanvasRenderingContext2D) => {
|
|
114
121
|
context.drawImage(video, 0, 0, composite.width, composite.height)
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
122
|
+
// Resolved per frame: the renderer replaces this element on every pipeline rebuild. A canvas
|
|
123
|
+
// jassub has transferred reports the size of the last frame its worker committed, so the zero
|
|
124
|
+
// check still keeps drawImage off a source with no dimensions to read.
|
|
125
|
+
const surface = subtitles.querySelector('canvas')
|
|
126
|
+
if (surface && surface.width > 0 && surface.height > 0) {
|
|
127
|
+
context.drawImage(surface, 0, 0, composite.width, composite.height)
|
|
118
128
|
}
|
|
119
129
|
}
|
|
120
130
|
|
|
@@ -254,18 +264,23 @@ export const createPictureInPicture = (options: PictureInPictureOptions): Pictur
|
|
|
254
264
|
* The real element is dimmed, never `display: none`: jassub sizes the subtitle canvas from its
|
|
255
265
|
* `offsetWidth`/`offsetHeight`, and a collapsed box silently yields a composite with no subtitles
|
|
256
266
|
* in it, which is exactly the thing being asked for.
|
|
267
|
+
*
|
|
268
|
+
* The LAYER is what gets hidden, rather than the canvas inside it. The canvas is replaced whenever
|
|
269
|
+
* the pipeline is rebuilt, so hiding that would leave the replacement visible over the composite
|
|
270
|
+
* (doubled subtitles) and would restore `display` onto an element that is no longer in the tree
|
|
271
|
+
* (subtitles gone for the rest of the session). The layer outlives both.
|
|
257
272
|
*/
|
|
258
273
|
const present = (mirror: HTMLVideoElement) => {
|
|
259
274
|
const videoOpacity = video.style.opacity
|
|
260
|
-
const
|
|
275
|
+
const subtitlesDisplay = subtitles.style.display
|
|
261
276
|
mirror.style.cssText =
|
|
262
277
|
'position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000;z-index:1'
|
|
263
278
|
video.style.opacity = '0'
|
|
264
|
-
|
|
279
|
+
subtitles.style.display = 'none'
|
|
265
280
|
restore = () => {
|
|
266
281
|
restore = undefined
|
|
267
282
|
video.style.opacity = videoOpacity
|
|
268
|
-
|
|
283
|
+
subtitles.style.display = subtitlesDisplay
|
|
269
284
|
}
|
|
270
285
|
}
|
|
271
286
|
|
|
@@ -13,7 +13,14 @@ export type MediaIndex = { pos: number, timestamp: number }
|
|
|
13
13
|
|
|
14
14
|
export type PlaybackOptions = {
|
|
15
15
|
videoElement: HTMLVideoElement
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The subtitle layer. The renderer creates its own canvas inside it, one per jassub instance.
|
|
18
|
+
*
|
|
19
|
+
* Renamed from `canvasElement` in the jassub 2 migration, deliberately: passing a canvas here now
|
|
20
|
+
* means jassub transfers it to a worker and deletes it on teardown, so a caller that kept handing
|
|
21
|
+
* over an element it owns has to see a type error rather than a runtime one.
|
|
22
|
+
*/
|
|
23
|
+
subtitleContainer: HTMLElement
|
|
17
24
|
read: (offset: number, size: number) => Promise<ArrayBuffer>
|
|
18
25
|
length: number
|
|
19
26
|
/** serves BOTH `libav.wasm` and `libav-jspi.wasm`; libav-wasm picks one on `WebAssembly.Suspending` */
|
|
@@ -114,7 +121,7 @@ export const terminateRemuxer = (remuxer: { worker: Worker, destroy: () => Promi
|
|
|
114
121
|
|
|
115
122
|
export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackController> => {
|
|
116
123
|
const {
|
|
117
|
-
videoElement,
|
|
124
|
+
videoElement, subtitleContainer, read, length, publicPath, libavWorkerUrl,
|
|
118
125
|
jassubWorkerUrl, jassubWasmUrl, jassubLegacyWasmUrl, defaultFontUrl, bufferSize = DEFAULT_BUFFER_SIZE,
|
|
119
126
|
audioStreamIndex, onReady, onError, onRecovered, onSeek, onSubtitleStreams,
|
|
120
127
|
onAudioStreams,
|
|
@@ -152,7 +159,7 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
152
159
|
|
|
153
160
|
const subtitles = createSubtitleRenderer({
|
|
154
161
|
video: videoElement,
|
|
155
|
-
|
|
162
|
+
container: subtitleContainer,
|
|
156
163
|
workerUrl: jassubWorkerUrl,
|
|
157
164
|
wasmUrl: jassubWasmUrl,
|
|
158
165
|
legacyWasmUrl: jassubLegacyWasmUrl,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ASSEvent } from 'jassub/dist/worker/util'
|
|
2
2
|
import type { Attachment, SubtitleFragment } from 'libav-wasm/build/worker'
|
|
3
3
|
|
|
4
4
|
import JASSUB from 'jassub'
|
|
@@ -9,30 +9,32 @@ export type SubtitleStream = { streamIndex: number, title: string, language: str
|
|
|
9
9
|
/** -1 turns subtitles off. It frees the track and matches no header, so nothing is set. */
|
|
10
10
|
export const SUBTITLES_OFF = -1
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* jassub's `ASS_Event` types `Style` as a string, which the library it wraps does not agree with:
|
|
14
|
-
* the field is written straight through to libass's `int Style`, an index into the track's style
|
|
15
|
-
* list. Corrected once, here, so a name cannot end up in it again.
|
|
16
|
-
*/
|
|
17
|
-
type StyledEvent = Omit<ASS_Event, 'Style'> & { Style: number }
|
|
18
|
-
|
|
19
|
-
const createEvent = (jassub: JASSUB, event: StyledEvent) => jassub.createEvent(event as unknown as ASS_Event)
|
|
20
|
-
|
|
21
12
|
type SubtitleHeaderPart = { type: 'header', streamIndex: number, content: string, eventsContent: string, styles: Map<string, number> }
|
|
22
|
-
type SubtitleDialoguePart = { type: 'dialogue', streamIndex: number, index: number, assEvent:
|
|
13
|
+
type SubtitleDialoguePart = { type: 'dialogue', streamIndex: number, index: number, assEvent: ASSEvent }
|
|
23
14
|
|
|
24
15
|
export type SubtitleRendererOptions = {
|
|
25
16
|
video: HTMLVideoElement
|
|
26
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Where the subtitle surface is mounted.
|
|
19
|
+
*
|
|
20
|
+
* A container, not the canvas itself. From jassub 2 the canvas belongs to the renderer: the
|
|
21
|
+
* constructor transfers it to a worker, which an element accepts exactly once for its whole life,
|
|
22
|
+
* and `destroy()` removes it from the document. Neither is survivable for an element someone else
|
|
23
|
+
* owns, and this pipeline is rebuilt in place on an audio track change and on an element recovery.
|
|
24
|
+
* So one canvas is created here per jassub instance, inside this container.
|
|
25
|
+
*
|
|
26
|
+
* The live canvas is `container.querySelector('canvas')`, and it is replaced on every rebuild.
|
|
27
|
+
*/
|
|
28
|
+
container: HTMLElement
|
|
27
29
|
workerUrl: string
|
|
28
30
|
/** The SIMD build, `jassub-worker-modern.wasm`. Used wherever WebAssembly SIMD is available. */
|
|
29
31
|
wasmUrl: string
|
|
30
32
|
/**
|
|
31
|
-
* The non-SIMD build, `jassub-worker.wasm`, for
|
|
33
|
+
* The non-SIMD build, `jassub-worker.wasm`, for anything without WebAssembly SIMD.
|
|
32
34
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
35
|
+
* Optional in practice as well as in the type, unlike under jassub 1: v2 defaults both wasm urls to
|
|
36
|
+
* its own files resolved against `import.meta.url`, so leaving this unset degrades to whatever the
|
|
37
|
+
* bundler emitted rather than failing outright.
|
|
36
38
|
*/
|
|
37
39
|
legacyWasmUrl?: string
|
|
38
40
|
/**
|
|
@@ -48,10 +50,10 @@ export type SubtitleRendererOptions = {
|
|
|
48
50
|
/**
|
|
49
51
|
* A url the WORKER will fetch, resolved against the document first.
|
|
50
52
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
53
|
+
* A relative url handed to the worker resolves against the worker's own url inside it, which is not
|
|
54
|
+
* the document's. For the wasm that fails loudly. For the fallback font it fails in SILENCE: libass
|
|
55
|
+
* is left with no face to draw with, so the track renders nothing at all and the picture simply has
|
|
56
|
+
* no subtitles on it, with no error anywhere to say why.
|
|
55
57
|
*
|
|
56
58
|
* `workerUrl` is deliberately not passed through here. `new Worker()` is called on the main thread,
|
|
57
59
|
* where a relative url already resolves against the document.
|
|
@@ -78,9 +80,11 @@ const headerStyles = (content: string) =>
|
|
|
78
80
|
/**
|
|
79
81
|
* The index libass will resolve this event's style to.
|
|
80
82
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
83
|
+
* `ASSEvent.Style` is written straight through to an `int`, so handing it a style NAME stores 0,
|
|
84
|
+
* which is libass's own default: Arial at size 18, with margins and a drop shadow of its own.
|
|
85
|
+
* Subtitles still appear, in a face and a size the file never asked for. jassub 1 typed the field as
|
|
86
|
+
* a string, which is what made that easy to do; jassub 2 types it `number` and agrees with libass,
|
|
87
|
+
* but the resolution still has to happen somewhere and this is it.
|
|
84
88
|
*
|
|
85
89
|
* Falling back to the header's own "Default", then to 0, is what libass does for a name it cannot
|
|
86
90
|
* find. The leading-`*` strip and the case fold on "Default" are its normalisation, kept so a
|
|
@@ -123,23 +127,30 @@ const toDialoguePart = (header: SubtitleHeaderPart, fragment: SubtitleFragment &
|
|
|
123
127
|
streamIndex: fragment.streamIndex,
|
|
124
128
|
index: dialogueIndex,
|
|
125
129
|
assEvent: {
|
|
126
|
-
|
|
130
|
+
Name: event.Name ?? '',
|
|
131
|
+
// libass wants the raw effect field and ass-compiler has already destructured it into an
|
|
132
|
+
// object, so only the name survives the round trip. That is parity rather than a regression:
|
|
133
|
+
// jassub 1 was handed the object itself, which reached libass's `char*` as "[object Object]",
|
|
134
|
+
// and libass matches an effect by a "Banner;" or "Scroll up;" prefix, so neither form has ever
|
|
135
|
+
// selected one.
|
|
136
|
+
Effect: event.Effect?.name ?? '',
|
|
137
|
+
Layer: event.Layer,
|
|
138
|
+
MarginL: event.MarginL,
|
|
139
|
+
MarginR: event.MarginR,
|
|
140
|
+
MarginV: event.MarginV,
|
|
127
141
|
Style: styleIndex(header, event.Style),
|
|
128
|
-
Effect: event.Effect ?? '',
|
|
129
142
|
Text: event.Text.raw,
|
|
130
|
-
Duration: (event.End - event.Start) * 1000,
|
|
131
143
|
Start: event.Start * 1000,
|
|
132
|
-
|
|
144
|
+
Duration: (event.End - event.Start) * 1000,
|
|
133
145
|
ReadOrder: dialogueIndex,
|
|
134
|
-
|
|
135
|
-
} as StyledEvent,
|
|
146
|
+
},
|
|
136
147
|
}
|
|
137
148
|
}
|
|
138
149
|
|
|
139
150
|
export type SubtitleRenderer = ReturnType<typeof createSubtitleRenderer>
|
|
140
151
|
|
|
141
152
|
export const createSubtitleRenderer = (options: SubtitleRendererOptions) => {
|
|
142
|
-
const { video,
|
|
153
|
+
const { video, container, workerUrl } = options
|
|
143
154
|
const wasmUrl = workerFetched(options.wasmUrl)!
|
|
144
155
|
const legacyWasmUrl = workerFetched(options.legacyWasmUrl)
|
|
145
156
|
const defaultFontUrl = workerFetched(options.defaultFontUrl)
|
|
@@ -150,31 +161,157 @@ export const createSubtitleRenderer = (options: SubtitleRendererOptions) => {
|
|
|
150
161
|
const dialogues = new Map<number, Map<number, SubtitleDialoguePart>>()
|
|
151
162
|
let selected: number | undefined
|
|
152
163
|
let onStreams = options.onStreams
|
|
164
|
+
/**
|
|
165
|
+
* Terminal, unlike a null `jassub`.
|
|
166
|
+
*
|
|
167
|
+
* `pushFragments` boots on the first header it sees and tests `!jassub` to decide, so a fragment
|
|
168
|
+
* that lands after teardown (a demuxer read resolving into a torn-down pipeline, which is ordinary
|
|
169
|
+
* on the `./engine` surface) would start a SECOND worker with a second canvas inside a container
|
|
170
|
+
* nobody is watching any more. The live canvas is found with `querySelector`, so the stale one
|
|
171
|
+
* would win and picture in picture would composite a surface that is never painted again.
|
|
172
|
+
*/
|
|
173
|
+
let destroyed = false
|
|
174
|
+
|
|
175
|
+
const rendererFailed = (error: unknown) => {
|
|
176
|
+
console.warn('the subtitle renderer failed; the file plays without subtitles', error)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Run something against the worker, once there is a worker to run it against.
|
|
181
|
+
*
|
|
182
|
+
* `instance.renderer` is assigned by the promise `ready` resolves, so nothing can be called before
|
|
183
|
+
* then and a call made too early throws on `undefined`. Ordering after that is free rather than
|
|
184
|
+
* something this has to arrange: abslink posts each call synchronously from its proxy's `apply`
|
|
185
|
+
* trap, and the worker runs the method synchronously in its own message handler, so calls land in
|
|
186
|
+
* the order they were made whether or not each one is awaited. Reactions registered on one
|
|
187
|
+
* already-settled promise also run in registration order, so this preserves it too.
|
|
188
|
+
*
|
|
189
|
+
* The identity check is what stops a teardown that happened during the boot from talking to a
|
|
190
|
+
* worker that is on its way out.
|
|
191
|
+
*
|
|
192
|
+
* `run` RETURNS its calls rather than dropping them. Every proxied call is a promise that rejects
|
|
193
|
+
* when the worker throws, which libass does on a header it will not take and once it is over its
|
|
194
|
+
* glyph or memory limit, and a dropped one surfaces as a bare `unhandledrejection` in the host app
|
|
195
|
+
* with nothing naming subtitles in it.
|
|
196
|
+
*/
|
|
197
|
+
const onRenderer = (run: (renderer: JASSUB['renderer']) => unknown) => {
|
|
198
|
+
const instance = jassub
|
|
199
|
+
if (!instance) return
|
|
200
|
+
void instance.ready
|
|
201
|
+
.then(() => (jassub === instance ? run(instance.renderer) : undefined))
|
|
202
|
+
.catch(rendererFailed)
|
|
203
|
+
}
|
|
153
204
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
205
|
+
let repaintQueued = false
|
|
206
|
+
/**
|
|
207
|
+
* Ask for one frame.
|
|
208
|
+
*
|
|
209
|
+
* jassub 2 draws only from `requestVideoFrameCallback`, so a PAUSED video presents no frames and
|
|
210
|
+
* nothing repaints. Anything that changes what libass would draw has to ask for a frame itself, or
|
|
211
|
+
* a track switch and a seek while paused both leave the previous line on screen until playback
|
|
212
|
+
* resumes. jassub 1 needed none of this because the 100ms `setCurrentTime` tick this replaces
|
|
213
|
+
* redrew unconditionally.
|
|
214
|
+
*
|
|
215
|
+
* Coalesced through a microtask because events arrive in bursts of hundreds and one frame covers
|
|
216
|
+
* the whole burst. The burst is synchronous, so the microtask always runs after all of it, and a
|
|
217
|
+
* playing video therefore costs at most one extra draw per burst.
|
|
218
|
+
*/
|
|
219
|
+
const repaint = () => {
|
|
220
|
+
if (repaintQueued) return
|
|
221
|
+
repaintQueued = true
|
|
222
|
+
queueMicrotask(() => {
|
|
223
|
+
repaintQueued = false
|
|
224
|
+
const instance = jassub
|
|
225
|
+
// `videoWidth` is 0 until metadata arrives, and asking jassub to draw then sizes its surface to
|
|
226
|
+
// nothing. Nothing else is required: this deliberately does NOT skip the draw when the element
|
|
227
|
+
// is playing. "Playing" is not "presenting frames", and the gap between them is exactly where
|
|
228
|
+
// subtitles get stuck. A torrent-backed source whose SourceBuffer runs dry keeps `paused` false
|
|
229
|
+
// while presenting nothing, so a viewer who turns subtitles off mid-stall would watch the line
|
|
230
|
+
// stay on screen until the buffer refilled. One forced draw per change is far cheaper than
|
|
231
|
+
// reasoning about which stalls count.
|
|
232
|
+
if (!instance || !video.videoWidth) return
|
|
233
|
+
void instance.ready
|
|
234
|
+
.then(() => {
|
|
235
|
+
if (jassub !== instance) return undefined
|
|
236
|
+
return instance.manualRender({
|
|
237
|
+
expectedDisplayTime: performance.now(),
|
|
238
|
+
width: video.videoWidth,
|
|
239
|
+
height: video.videoHeight,
|
|
240
|
+
mediaTime: video.currentTime,
|
|
241
|
+
}, true)
|
|
242
|
+
})
|
|
243
|
+
.catch(rendererFailed)
|
|
244
|
+
})
|
|
245
|
+
}
|
|
157
246
|
|
|
158
|
-
|
|
247
|
+
/**
|
|
248
|
+
* The element events that change what should be on screen without presenting a frame.
|
|
249
|
+
*
|
|
250
|
+
* `loadedmetadata` is the one that is easy to miss and fatal to leave out: the first subtitle
|
|
251
|
+
* fragments arrive BEFORE the element has parsed metadata, so the repaint they ask for is skipped
|
|
252
|
+
* for having no `videoWidth` to size a surface from, and if nothing asks again a paused player
|
|
253
|
+
* shows no subtitles at all. `seeked` covers scrubbing while paused, and `pause` covers landing on
|
|
254
|
+
* a frame whose line arrived after it was presented.
|
|
255
|
+
*
|
|
256
|
+
* Trimming this list was tried and reverted. `['loadedmetadata']` alone fails
|
|
257
|
+
* `subtitle-paused-repaint.browser.test.tsx`'s turn-off case four runs out of four, while adding
|
|
258
|
+
* back EITHER of the other two passes. Neither of those events fires in that test, so what the
|
|
259
|
+
* extra listener buys is timing rather than a trigger, and the honest conclusion is that the clear
|
|
260
|
+
* after a `freeTrack` is more delicate than it looks. Do not shorten this list without running that
|
|
261
|
+
* case several times.
|
|
262
|
+
*/
|
|
263
|
+
const REPAINT_ON = ['loadedmetadata', 'seeked', 'pause'] as const
|
|
264
|
+
const onRepaintEvent = () => repaint()
|
|
159
265
|
|
|
160
266
|
const bootJassub = (header: SubtitleHeaderPart) => {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
267
|
+
const canvas = document.createElement('canvas')
|
|
268
|
+
container.append(canvas)
|
|
269
|
+
try {
|
|
270
|
+
jassub = new JASSUB({
|
|
271
|
+
video,
|
|
272
|
+
canvas,
|
|
273
|
+
subContent: header.content,
|
|
274
|
+
workerUrl,
|
|
275
|
+
modernWasmUrl: wasmUrl,
|
|
276
|
+
...legacyWasmUrl ? { wasmUrl: legacyWasmUrl } : {},
|
|
277
|
+
fonts: attachments.map(([, data]) => data),
|
|
278
|
+
availableFonts: {
|
|
279
|
+
...Object.fromEntries(attachments),
|
|
280
|
+
...(defaultFontUrl ? { 'liberation sans': defaultFontUrl } : {}),
|
|
281
|
+
},
|
|
282
|
+
// jassub 2 defaults this to 'local', which asks the browser for the local-fonts permission
|
|
283
|
+
// the first time libass reports a face it does not have. The container ships its own fonts
|
|
284
|
+
// and a player has no business raising that prompt.
|
|
285
|
+
queryFonts: false,
|
|
286
|
+
})
|
|
287
|
+
} catch (error) {
|
|
288
|
+
canvas.remove()
|
|
289
|
+
rendererFailed(error)
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
// A boot that fails worker-side rejects `ready`, and jassub's own ResizeObserver awaits `ready`
|
|
293
|
+
// on every layout change, so an instance left in that state throws a fresh unhandled rejection
|
|
294
|
+
// for the rest of the session.
|
|
295
|
+
void jassub.ready.catch(rendererFailed)
|
|
296
|
+
for (const type of REPAINT_ON) video.addEventListener(type, onRepaintEvent)
|
|
297
|
+
/*
|
|
298
|
+
* The default face is the one font jassub loads LAZILY. Attachments are handed to the constructor
|
|
299
|
+
* and `ready` waits for them, but `availableFonts` is consulted only when libass reports a
|
|
300
|
+
* missing face part way through a draw, and the fetch that follows lands after the frame that
|
|
301
|
+
* needed it. Driven by rVFC the next frame picks it up and the cost is one frame of unstyled
|
|
302
|
+
* text; PAUSED there is no next frame, so the first line stays unpainted until the viewer presses
|
|
303
|
+
* play. Measured as a RACE rather than a certainty: three runs of subtitle-header.browser.test.tsx
|
|
304
|
+
* against one repaint scored two blank and one painted.
|
|
305
|
+
*
|
|
306
|
+
* `addFonts` is the fix because it is a signal rather than a delay: it resolves when the face is
|
|
307
|
+
* in libass, which is the moment a repaint is worth asking for.
|
|
308
|
+
*/
|
|
309
|
+
if (defaultFontUrl) {
|
|
310
|
+
onRenderer((renderer) => {
|
|
311
|
+
void Promise.resolve(renderer.addFonts([defaultFontUrl])).then(repaint, rendererFailed)
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
repaint()
|
|
178
315
|
}
|
|
179
316
|
|
|
180
317
|
const pushAttachments = (incoming: Attachment[]) => {
|
|
@@ -182,6 +319,7 @@ export const createSubtitleRenderer = (options: SubtitleRendererOptions) => {
|
|
|
182
319
|
}
|
|
183
320
|
|
|
184
321
|
const pushFragments = (fragments: SubtitleFragment[]) => {
|
|
322
|
+
const incoming: ASSEvent[] = []
|
|
185
323
|
for (const fragment of fragments) {
|
|
186
324
|
if (fragment.type === 'header') {
|
|
187
325
|
if (headers.has(fragment.streamIndex)) continue
|
|
@@ -191,7 +329,7 @@ export const createSubtitleRenderer = (options: SubtitleRendererOptions) => {
|
|
|
191
329
|
streams.push({ streamIndex: fragment.streamIndex, title: fragment.title, language: fragment.language })
|
|
192
330
|
onStreams?.([...streams])
|
|
193
331
|
if (selected === undefined) selected = fragment.streamIndex
|
|
194
|
-
if (!jassub) bootJassub(header)
|
|
332
|
+
if (!jassub && !destroyed) bootJassub(header)
|
|
195
333
|
} else {
|
|
196
334
|
const header = headers.get(fragment.streamIndex)
|
|
197
335
|
if (!header) continue
|
|
@@ -200,21 +338,31 @@ export const createSubtitleRenderer = (options: SubtitleRendererOptions) => {
|
|
|
200
338
|
const part = toDialoguePart(header, fragment)
|
|
201
339
|
if (byIndex.has(part.index)) continue
|
|
202
340
|
byIndex.set(part.index, part)
|
|
203
|
-
if (selected === fragment.streamIndex
|
|
341
|
+
if (selected === fragment.streamIndex) incoming.push(part.assEvent)
|
|
204
342
|
}
|
|
205
343
|
}
|
|
344
|
+
if (!incoming.length) return
|
|
345
|
+
// one hop for the whole burst rather than one per event
|
|
346
|
+
onRenderer((renderer) => Promise.all(incoming.map((event) => renderer.createEvent(event))))
|
|
347
|
+
repaint()
|
|
206
348
|
}
|
|
207
349
|
|
|
208
350
|
const selectStream = (streamIndex: number | undefined) => {
|
|
209
351
|
const next = streamIndex ?? SUBTITLES_OFF
|
|
210
352
|
if (next === selected || !jassub) return
|
|
211
353
|
selected = next
|
|
212
|
-
jassub.freeTrack()
|
|
213
354
|
const header = headers.get(next)
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
355
|
+
const events = [...dialogues.get(next)?.values() ?? []].map((part) => part.assEvent)
|
|
356
|
+
onRenderer((renderer) => {
|
|
357
|
+
const calls: unknown[] = [renderer.freeTrack()]
|
|
358
|
+
if (header) {
|
|
359
|
+
calls.push(renderer.setTrack(header.content))
|
|
360
|
+
for (const event of events) calls.push(renderer.createEvent(event))
|
|
361
|
+
}
|
|
362
|
+
return Promise.all(calls)
|
|
363
|
+
})
|
|
364
|
+
// outside the callback so that turning subtitles OFF while paused clears the picture too
|
|
365
|
+
repaint()
|
|
218
366
|
}
|
|
219
367
|
|
|
220
368
|
return {
|
|
@@ -225,10 +373,14 @@ export const createSubtitleRenderer = (options: SubtitleRendererOptions) => {
|
|
|
225
373
|
getSelectedStream: () => selected,
|
|
226
374
|
setOnStreams: (cb: (streams: SubtitleStream[]) => void) => { onStreams = cb },
|
|
227
375
|
destroy: () => {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
jassub?.destroy()
|
|
376
|
+
destroyed = true
|
|
377
|
+
const instance = jassub
|
|
231
378
|
jassub = undefined
|
|
379
|
+
for (const type of REPAINT_ON) video.removeEventListener(type, onRepaintEvent)
|
|
380
|
+
// jassub removes its own canvas at the top of destroy(), before it awaits anything, so the
|
|
381
|
+
// element is gone synchronously. The rest is a worker round trip that a dead worker never
|
|
382
|
+
// answers, which is why nothing here waits for it.
|
|
383
|
+
void instance?.destroy().catch(() => {})
|
|
232
384
|
},
|
|
233
385
|
}
|
|
234
386
|
}
|