jassub 1.7.2 → 1.7.4

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/dist/js/jassub.js CHANGED
@@ -1,799 +1,836 @@
1
- import 'rvfc-polyfill'
2
-
3
- const webYCbCrMap = {
4
- bt709: 'BT709',
5
- // these might not be exactly correct? oops?
6
- bt470bg: 'BT601', // alias BT.601 PAL... whats the difference?
7
- smpte170m: 'BT601'// alias BT.601 NTSC... whats the difference?
8
- }
9
-
10
- const colorMatrixConversionMap = {
11
- BT601: {
12
- BT709: '1.0863 -0.0723 -0.014 0 0 0.0965 0.8451 0.0584 0 0 -0.0141 -0.0277 1.0418'
13
- },
14
- BT709: {
15
- BT601: '0.9137 0.0784 0.0079 0 0 -0.1049 1.1722 -0.0671 0 0 0.0096 0.0322 0.9582'
16
- },
17
- FCC: {
18
- BT709: '1.0873 -0.0736 -0.0137 0 0 0.0974 0.8494 0.0531 0 0 -0.0127 -0.0251 1.0378',
19
- BT601: '1.001 -0.0008 -0.0002 0 0 0.0009 1.005 -0.006 0 0 0.0013 0.0027 0.996'
20
- },
21
- SMPTE240M: {
22
- BT709: '0.9993 0.0006 0.0001 0 0 -0.0004 0.9812 0.0192 0 0 -0.0034 -0.0114 1.0148',
23
- BT601: '0.913 0.0774 0.0096 0 0 -0.1051 1.1508 -0.0456 0 0 0.0063 0.0207 0.973'
24
- }
25
- }
26
-
27
- /**
28
- * New JASSUB instance.
29
- * @class
30
- */
31
- export default class JASSUB extends EventTarget {
32
- /**
33
- * @param {Object} options Settings object.
34
- * @param {HTMLVideoElement} options.video Video to use as target for rendering and event listeners. Optional if canvas is specified instead.
35
- * @param {HTMLCanvasElement} [options.canvas=HTMLCanvasElement] Canvas to use for manual handling. Not required if video is specified.
36
- * @param {'js'|'wasm'} [options.blendMode='js'] Which image blending mode to use. WASM will perform better on lower end devices, JS will perform better if the device and browser supports hardware acceleration.
37
- * @param {Boolean} [options.asyncRender=true] Whether or not to use async rendering, which offloads the CPU by creating image bitmaps on the GPU.
38
- * @param {Boolean} [options.offscreenRender=true] Whether or not to render things fully on the worker, greatly reduces CPU usage.
39
- * @param {Boolean} [options.onDemandRender=true] Whether or not to render subtitles as the video player decodes renders, rather than predicting which frame the player is on using events.
40
- * @param {Number} [options.targetFps=24] Target FPS to render subtitles at. Ignored when onDemandRender is enabled.
41
- * @param {Number} [options.timeOffset=0] Subtitle time offset in seconds.
42
- * @param {Boolean} [options.debug=false] Whether or not to print debug information.
43
- * @param {Number} [options.prescaleFactor=1.0] Scale down (< 1.0) the subtitles canvas to improve performance at the expense of quality, or scale it up (> 1.0).
44
- * @param {Number} [options.prescaleHeightLimit=1080] The height in pixels beyond which the subtitles canvas won't be prescaled.
45
- * @param {Number} [options.maxRenderHeight=0] The maximum rendering height in pixels of the subtitles canvas. Beyond this subtitles will be upscaled by the browser.
46
- * @param {Boolean} [options.dropAllAnimations=false] Attempt to discard all animated tags. Enabling this may severly mangle complex subtitles and should only be considered as an last ditch effort of uncertain success for hardware otherwise incapable of displaing anything. Will not reliably work with manually edited or allocated events.
47
- * @param {Boolean} [options.dropAllBlur=false] The holy grail of performance gains. If heavy TS lags a lot, disabling this will make it ~x10 faster. This drops blur from all added subtitle tracks making most text and backgrounds look sharper, this is way less intrusive than dropping all animations, while still offering major performance gains.
48
- * @param {String} [options.workerUrl='jassub-worker.js'] The URL of the worker.
49
- * @param {String} [options.wasmUrl='jassub-worker.wasm'] The URL of the worker WASM.
50
- * @param {String} [options.legacyWasmUrl='jassub-worker.wasm.js'] The URL of the worker WASM. Only loaded if the browser doesn't support WASM.
51
- * @param {String} options.modernWasmUrl The URL of the modern worker WASM. This includes faster ASM instructions, but is only supported by newer browsers, disabled if the URL isn't defined.
52
- * @param {String} [options.subUrl=options.subContent] The URL of the subtitle file to play.
53
- * @param {String} [options.subContent=options.subUrl] The content of the subtitle file to play.
54
- * @param {String[]|Uint8Array[]} [options.fonts] An array of links or Uint8Arrays to the fonts used in the subtitle. If Uint8Array is used the array is copied, not referenced. This forces all the fonts in this array to be loaded by the renderer, regardless of if they are used.
55
- * @param {Object} [options.availableFonts={'liberation sans': './default.woff2'}] Object with all available fonts - Key is font family in lower case, value is link or Uint8Array: { arial: '/font1.ttf' }. These fonts are selectively loaded if detected as used in the current subtitle track.
56
- * @param {String} [options.fallbackFont='liberation sans'] The font family key of the fallback font in availableFonts to use if the other font for the style is missing special glyphs or unicode.
57
- * @param {Boolean} [options.useLocalFonts=false] If the Local Font Access API is enabled [chrome://flags/#font-access], the library will query for permissions to use local fonts and use them if any are missing. The permission can be queried beforehand using navigator.permissions.request({ name: 'local-fonts' }).
58
- * @param {Number} [options.libassMemoryLimit] libass bitmap cache memory limit in MiB (approximate).
59
- * @param {Number} [options.libassGlyphLimit] libass glyph cache memory limit in MiB (approximate).
60
- */
61
- constructor (options = {}) {
62
- super()
63
- if (!globalThis.Worker) {
64
- this.destroy('Worker not supported')
65
- }
66
-
67
- this._loaded = new Promise(resolve => {
68
- this._init = resolve
69
- })
70
-
71
- JASSUB._test()
72
- this._onDemandRender = 'requestVideoFrameCallback' in HTMLVideoElement.prototype && (options.onDemandRender ?? true)
73
-
74
- // don't support offscreen rendering on custom canvases, as we can't replace it if colorSpace doesn't match
75
- this._offscreenRender = 'transferControlToOffscreen' in HTMLCanvasElement.prototype && !options.canvas && (options.offscreenRender ?? true)
76
-
77
- this.timeOffset = options.timeOffset || 0
78
- this._video = options.video
79
- this._videoHeight = 0
80
- this._videoWidth = 0
81
- this._videoColorSpace = null
82
- this._canvas = options.canvas
83
- if (this._video && !this._canvas) {
84
- this._canvasParent = document.createElement('div')
85
- this._canvasParent.className = 'JASSUB'
86
- this._canvasParent.style.position = 'relative'
87
-
88
- this._createCanvas()
89
-
90
- if (this._video.nextSibling) {
91
- this._video.parentNode.insertBefore(this._canvasParent, this._video.nextSibling)
92
- } else {
93
- this._video.parentNode.appendChild(this._canvasParent)
94
- }
95
- } else if (!this._canvas) {
96
- this.destroy('Don\'t know where to render: you should give video or canvas in options.')
97
- }
98
-
99
- this._bufferCanvas = document.createElement('canvas')
100
- this._bufferCtx = this._bufferCanvas.getContext('2d')
101
-
102
- this._canvasctrl = this._offscreenRender ? this._canvas.transferControlToOffscreen() : this._canvas
103
- this._ctx = !this._offscreenRender && this._canvasctrl.getContext('2d')
104
-
105
- this._lastRenderTime = 0
106
- this.debug = !!options.debug
107
-
108
- this.prescaleFactor = options.prescaleFactor || 1.0
109
- this.prescaleHeightLimit = options.prescaleHeightLimit || 1080
110
- this.maxRenderHeight = options.maxRenderHeight || 0 // 0 - no limit.
111
-
112
- this._boundResize = this.resize.bind(this)
113
- this._boundTimeUpdate = this._timeupdate.bind(this)
114
- this._boundSetRate = this.setRate.bind(this)
115
- this._boundUpdateColorSpace = this._updateColorSpace.bind(this)
116
- if (this._video) this.setVideo(options.video)
117
-
118
- if (this._onDemandRender) {
119
- this.busy = false
120
- this._lastDemandTime = null
121
- }
122
-
123
- this._worker = new Worker(options.workerUrl || 'jassub-worker.js')
124
- this._worker.onmessage = e => this._onmessage(e)
125
- this._worker.onerror = e => this._error(e)
126
-
127
- this._worker.postMessage({
128
- target: 'init',
129
- wasmUrl: JASSUB._supportsSIMD && options.modernWasmUrl ? options.modernWasmUrl : options.wasmUrl || 'jassub-worker.wasm',
130
- legacyWasmUrl: options.legacyWasmUrl || 'jassub-worker.wasm.js',
131
- asyncRender: typeof createImageBitmap !== 'undefined' && (options.asyncRender ?? true),
132
- onDemandRender: this._onDemandRender,
133
- width: this._canvasctrl.width || 0,
134
- height: this._canvasctrl.height || 0,
135
- blendMode: options.blendMode || 'js',
136
- subUrl: options.subUrl,
137
- subContent: options.subContent || null,
138
- fonts: options.fonts || [],
139
- availableFonts: options.availableFonts || { 'liberation sans': './default.woff2' },
140
- fallbackFont: options.fallbackFont || 'liberation sans',
141
- debug: this.debug,
142
- targetFps: options.targetFps || 24,
143
- dropAllAnimations: options.dropAllAnimations,
144
- dropAllBlur: options.dropAllBlur,
145
- libassMemoryLimit: options.libassMemoryLimit || 0,
146
- libassGlyphLimit: options.libassGlyphLimit || 0,
147
- useLocalFonts: typeof queryLocalFonts !== 'undefined' && (options.useLocalFonts ?? true)
148
- })
149
- if (this._offscreenRender === true) this.sendMessage('offscreenCanvas', null, [this._canvasctrl])
150
- }
151
-
152
- _createCanvas () {
153
- this._canvas = document.createElement('canvas')
154
- this._canvas.style.display = 'block'
155
- this._canvas.style.position = 'absolute'
156
- this._canvas.style.pointerEvents = 'none'
157
- this._canvasParent.appendChild(this._canvas)
158
- }
159
-
160
- // test support for WASM, ImageData, alphaBug, but only once, on init so it doesn't run when first running the page
161
- static _supportsSIMD = null
162
- static _hasAlphaBug = null
163
-
164
- static _test () {
165
- // check if ran previously
166
- if (JASSUB._supportsSIMD !== null) return null
167
-
168
- try {
169
- JASSUB._supportsSIMD = WebAssembly.validate(Uint8Array.of(0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 123, 3, 2, 1, 0, 10, 10, 1, 8, 0, 65, 0, 253, 15, 253, 98, 11))
170
- } catch (e) {
171
- JASSUB._supportsSIMD = false
172
- }
173
-
174
- const canvas1 = document.createElement('canvas')
175
- const ctx1 = canvas1.getContext('2d', { willReadFrequently: true })
176
- // test ImageData constructor
177
- if (typeof ImageData.prototype.constructor === 'function') {
178
- try {
179
- // try actually calling ImageData, as on some browsers it's reported
180
- // as existing but calling it errors out as "TypeError: Illegal constructor"
181
- // eslint-disable-next-line no-new
182
- new ImageData(new Uint8ClampedArray([0, 0, 0, 0]), 1, 1)
183
- } catch (e) {
184
- console.log('Detected that ImageData is not constructable despite browser saying so')
185
-
186
- self.ImageData = function (data, width, height) {
187
- const imageData = ctx1.createImageData(width, height)
188
- if (data) imageData.data.set(data)
189
- return imageData
190
- }
191
- }
192
- }
193
-
194
- // Test for alpha bug, where e.g. WebKit can render a transparent pixel
195
- // (with alpha == 0) as non-black which then leads to visual artifacts.
196
- const canvas2 = document.createElement('canvas')
197
- const ctx2 = canvas2.getContext('2d', { willReadFrequently: true })
198
-
199
- canvas1.width = canvas2.width = 1
200
- canvas1.height = canvas2.height = 1
201
- ctx1.clearRect(0, 0, 1, 1)
202
- ctx2.clearRect(0, 0, 1, 1)
203
- const prePut = ctx2.getImageData(0, 0, 1, 1).data
204
- ctx1.putImageData(new ImageData(new Uint8ClampedArray([0, 255, 0, 0]), 1, 1), 0, 0)
205
- ctx2.drawImage(canvas1, 0, 0)
206
- const postPut = ctx2.getImageData(0, 0, 1, 1).data
207
- JASSUB._hasAlphaBug = prePut[1] !== postPut[1]
208
- if (JASSUB._hasAlphaBug) console.log('Detected a browser having issue with transparent pixels, applying workaround')
209
- canvas1.remove()
210
- canvas2.remove()
211
- }
212
-
213
- /**
214
- * Resize the canvas to given parameters. Auto-generated if values are ommited.
215
- * @param {Number} [width=0]
216
- * @param {Number} [height=0]
217
- * @param {Number} [top=0]
218
- * @param {Number} [left=0]
219
- * @param {Boolean} [force=false]
220
- */
221
- resize (width = 0, height = 0, top = 0, left = 0, force = this._video?.paused) {
222
- if ((!width || !height) && this._video) {
223
- const videoSize = this._getVideoPosition()
224
- let renderSize = null
225
- // support anamorphic video
226
- if (this._videoWidth) {
227
- const widthRatio = this._video.videoWidth / this._videoWidth
228
- const heightRatio = this._video.videoHeight / this._videoHeight
229
- renderSize = this._computeCanvasSize((videoSize.width || 0) / widthRatio, (videoSize.height || 0) / heightRatio)
230
- } else {
231
- renderSize = this._computeCanvasSize(videoSize.width || 0, videoSize.height || 0)
232
- }
233
- width = renderSize.width
234
- height = renderSize.height
235
- if (this._canvasParent) {
236
- top = videoSize.y - (this._canvasParent.getBoundingClientRect().top - this._video.getBoundingClientRect().top)
237
- left = videoSize.x
238
- }
239
- this._canvas.style.width = videoSize.width + 'px'
240
- this._canvas.style.height = videoSize.height + 'px'
241
- }
242
-
243
- this._canvas.style.top = top + 'px'
244
- this._canvas.style.left = left + 'px'
245
- if (force && this.busy === false) {
246
- this.busy = true
247
- } else {
248
- force = false
249
- }
250
- this.sendMessage('canvas', { width, height, force })
251
- }
252
-
253
- _getVideoPosition (width = this._video.videoWidth, height = this._video.videoHeight) {
254
- const videoRatio = width / height
255
- const { offsetWidth, offsetHeight } = this._video
256
- const elementRatio = offsetWidth / offsetHeight
257
- width = offsetWidth
258
- height = offsetHeight
259
- if (elementRatio > videoRatio) {
260
- width = Math.floor(offsetHeight * videoRatio)
261
- } else {
262
- height = Math.floor(offsetWidth / videoRatio)
263
- }
264
-
265
- const x = (offsetWidth - width) / 2
266
- const y = (offsetHeight - height) / 2
267
-
268
- return { width, height, x, y }
269
- }
270
-
271
- _computeCanvasSize (width = 0, height = 0) {
272
- const scalefactor = this.prescaleFactor <= 0 ? 1.0 : this.prescaleFactor
273
- const ratio = self.devicePixelRatio || 1
274
-
275
- width = width * ratio
276
- height = height * ratio
277
- if (height <= 0 || width <= 0) {
278
- width = 0
279
- height = 0
280
- } else {
281
- const sgn = scalefactor < 1 ? -1 : 1
282
- let newH = height * ratio
283
- if (sgn * newH * scalefactor <= sgn * this.prescaleHeightLimit) {
284
- newH *= scalefactor
285
- } else if (sgn * newH < sgn * this.prescaleHeightLimit) {
286
- newH = this.prescaleHeightLimit
287
- }
288
-
289
- if (this.maxRenderHeight > 0 && newH > this.maxRenderHeight) newH = this.maxRenderHeight
290
-
291
- width *= newH / height
292
- height = newH
293
- }
294
-
295
- return { width, height }
296
- }
297
-
298
- _timeupdate ({ type }) {
299
- const eventmap = {
300
- seeking: true,
301
- waiting: true,
302
- playing: false
303
- }
304
- const playing = eventmap[type]
305
- if (playing != null) this._playstate = playing
306
- this.setCurrentTime(this._video.paused || this._playstate, this._video.currentTime + this.timeOffset)
307
- }
308
-
309
- /**
310
- * Change the video to use as target for event listeners.
311
- * @param {HTMLVideoElement} video
312
- */
313
- setVideo (video) {
314
- if (video instanceof HTMLVideoElement) {
315
- this._removeListeners()
316
- this._video = video
317
- if (this._onDemandRender) {
318
- this._video.requestVideoFrameCallback(this._handleRVFC.bind(this))
319
- } else {
320
- this._playstate = video.paused
321
-
322
- video.addEventListener('timeupdate', this._boundTimeUpdate, false)
323
- video.addEventListener('progress', this._boundTimeUpdate, false)
324
- video.addEventListener('waiting', this._boundTimeUpdate, false)
325
- video.addEventListener('seeking', this._boundTimeUpdate, false)
326
- video.addEventListener('playing', this._boundTimeUpdate, false)
327
- video.addEventListener('ratechange', this._boundSetRate, false)
328
- video.addEventListener('resize', this._boundResize, false)
329
- }
330
- // everything else is unreliable for this, loadedmetadata and loadeddata included.
331
- if ('VideoFrame' in window) {
332
- video.addEventListener('loadedmetadata', this._boundUpdateColorSpace, false)
333
- if (video.readyState > 2) this._updateColorSpace()
334
- }
335
- if (video.videoWidth > 0) this.resize()
336
- // Support Element Resize Observer
337
- if (typeof ResizeObserver !== 'undefined') {
338
- if (!this._ro) this._ro = new ResizeObserver(() => this.resize())
339
- this._ro.observe(video)
340
- }
341
- } else {
342
- this._error('Video element invalid!')
343
- }
344
- }
345
-
346
- runBenchmark () {
347
- this.sendMessage('runBenchmark')
348
- }
349
-
350
- /**
351
- * Overwrites the current subtitle content.
352
- * @param {String} url URL to load subtitles from.
353
- */
354
- setTrackByUrl (url) {
355
- this.sendMessage('setTrackByUrl', { url })
356
- this._reAttachOffscreen()
357
- if (this._ctx) this._ctx.filter = 'none'
358
- }
359
-
360
- /**
361
- * Overwrites the current subtitle content.
362
- * @param {String} content Content of the ASS file.
363
- */
364
- setTrack (content) {
365
- this.sendMessage('setTrack', { content })
366
- this._reAttachOffscreen()
367
- if (this._ctx) this._ctx.filter = 'none'
368
- }
369
-
370
- /**
371
- * Free currently used subtitle track.
372
- */
373
- freeTrack () {
374
- this.sendMessage('freeTrack')
375
- }
376
-
377
- /**
378
- * Sets the playback state of the media.
379
- * @param {Boolean} isPaused Pause/Play subtitle playback.
380
- */
381
- setIsPaused (isPaused) {
382
- this.sendMessage('video', { isPaused })
383
- }
384
-
385
- /**
386
- * Sets the playback rate of the media [speed multiplier].
387
- * @param {Number} rate Playback rate.
388
- */
389
- setRate (rate) {
390
- this.sendMessage('video', { rate })
391
- }
392
-
393
- /**
394
- * Sets the current time, playback state and rate of the subtitles.
395
- * @param {Boolean} [isPaused] Pause/Play subtitle playback.
396
- * @param {Number} [currentTime] Time in seconds.
397
- * @param {Number} [rate] Playback rate.
398
- */
399
- setCurrentTime (isPaused, currentTime, rate) {
400
- this.sendMessage('video', { isPaused, currentTime, rate, colorSpace: this._videoColorSpace })
401
- }
402
-
403
- /**
404
- * @typedef {Object} ASS_Event
405
- * @property {Number} Start Start Time of the Event, in 0:00:00:00 format ie. Hrs:Mins:Secs:hundredths. This is the time elapsed during script playback at which the text will appear onscreen. Note that there is a single digit for the hours!
406
- * @property {Number} Duration End Time of the Event, in 0:00:00:00 format ie. Hrs:Mins:Secs:hundredths. This is the time elapsed during script playback at which the text will disappear offscreen. Note that there is a single digit for the hours!
407
- * @property {String} Style Style name. If it is "Default", then your own *Default style will be subtituted.
408
- * @property {String} Name Character name. This is the name of the character who speaks the dialogue. It is for information only, to make the script is easier to follow when editing/timing.
409
- * @property {Number} MarginL 4-figure Left Margin override. The values are in pixels. All zeroes means the default margins defined by the style are used.
410
- * @property {Number} MarginR 4-figure Right Margin override. The values are in pixels. All zeroes means the default margins defined by the style are used.
411
- * @property {Number} MarginV 4-figure Bottom Margin override. The values are in pixels. All zeroes means the default margins defined by the style are used.
412
- * @property {String} Effect Transition Effect. This is either empty, or contains information for one of the three transition effects implemented in SSA v4.x
413
- * @property {String} Text Subtitle Text. This is the actual text which will be displayed as a subtitle onscreen. Everything after the 9th comma is treated as the subtitle text, so it can include commas.
414
- * @property {Number} ReadOrder Number in order of which to read this event.
415
- * @property {Number} Layer Z-index overlap in which to render this event.
416
- * @property {Number} _index (Internal) index of the event.
417
- */
418
-
419
- /**
420
- * Create a new ASS event directly.
421
- * @param {ASS_Event} event
422
- */
423
- createEvent (event) {
424
- this.sendMessage('createEvent', { event })
425
- }
426
-
427
- /**
428
- * Overwrite the data of the event with the specified index.
429
- * @param {ASS_Event} event
430
- * @param {Number} index
431
- */
432
- setEvent (event, index) {
433
- this.sendMessage('setEvent', { event, index })
434
- }
435
-
436
- /**
437
- * Remove the event with the specified index.
438
- * @param {Number} index
439
- */
440
- removeEvent (index) {
441
- this.sendMessage('removeEvent', { index })
442
- }
443
-
444
- /**
445
- * Get all ASS events.
446
- * @param {function(Error|null, ASS_Event)} callback Function to callback when worker returns the events.
447
- */
448
- getEvents (callback) {
449
- this._fetchFromWorker({
450
- target: 'getEvents'
451
- }, (err, { events }) => {
452
- callback(err, events)
453
- })
454
- }
455
-
456
- /**
457
- * @typedef {Object} ASS_Style
458
- * @property {String} Name The name of the Style. Case sensitive. Cannot include commas.
459
- * @property {String} FontName The fontname as used by Windows. Case-sensitive.
460
- * @property {Number} FontSize Font size.
461
- * @property {Number} PrimaryColour A long integer BGR (blue-green-red) value. ie. the byte order in the hexadecimal equivelent of this number is BBGGRR
462
- * @property {Number} SecondaryColour A long integer BGR (blue-green-red) value. ie. the byte order in the hexadecimal equivelent of this number is BBGGRR
463
- * @property {Number} OutlineColour A long integer BGR (blue-green-red) value. ie. the byte order in the hexadecimal equivelent of this number is BBGGRR
464
- * @property {Number} BackColour This is the colour of the subtitle outline or shadow, if these are used. A long integer BGR (blue-green-red) value. ie. the byte order in the hexadecimal equivelent of this number is BBGGRR.
465
- * @property {Number} Bold This defines whether text is bold (true) or not (false). -1 is True, 0 is False. This is independant of the Italic attribute - you can have have text which is both bold and italic.
466
- * @property {Number} Italic Italic. This defines whether text is italic (true) or not (false). -1 is True, 0 is False. This is independant of the bold attribute - you can have have text which is both bold and italic.
467
- * @property {Number} Underline -1 or 0
468
- * @property {Number} StrikeOut -1 or 0
469
- * @property {Number} ScaleX Modifies the width of the font. [percent]
470
- * @property {Number} ScaleY Modifies the height of the font. [percent]
471
- * @property {Number} Spacing Extra space between characters. [pixels]
472
- * @property {Number} Angle The origin of the rotation is defined by the alignment. Can be a floating point number. [degrees]
473
- * @property {Number} BorderStyle 1=Outline + drop shadow, 3=Opaque box
474
- * @property {Number} Outline If BorderStyle is 1, then this specifies the width of the outline around the text, in pixels. Values may be 0, 1, 2, 3 or 4.
475
- * @property {Number} Shadow If BorderStyle is 1, then this specifies the depth of the drop shadow behind the text, in pixels. Values may be 0, 1, 2, 3 or 4. Drop shadow is always used in addition to an outline - SSA will force an outline of 1 pixel if no outline width is given.
476
- * @property {Number} Alignment This sets how text is "justified" within the Left/Right onscreen margins, and also the vertical placing. Values may be 1=Left, 2=Centered, 3=Right. Add 4 to the value for a "Toptitle". Add 8 to the value for a "Midtitle". eg. 5 = left-justified toptitle
477
- * @property {Number} MarginL This defines the Left Margin in pixels. It is the distance from the left-hand edge of the screen.The three onscreen margins (MarginL, MarginR, MarginV) define areas in which the subtitle text will be displayed.
478
- * @property {Number} MarginR This defines the Right Margin in pixels. It is the distance from the right-hand edge of the screen. The three onscreen margins (MarginL, MarginR, MarginV) define areas in which the subtitle text will be displayed.
479
- * @property {Number} MarginV This defines the vertical Left Margin in pixels. For a subtitle, it is the distance from the bottom of the screen. For a toptitle, it is the distance from the top of the screen. For a midtitle, the value is ignored - the text will be vertically centred.
480
- * @property {Number} Encoding This specifies the font character set or encoding and on multi-lingual Windows installations it provides access to characters used in multiple than one languages. It is usually 0 (zero) for English (Western, ANSI) Windows.
481
- * @property {Number} treat_fontname_as_pattern
482
- * @property {Number} Blur
483
- * @property {Number} Justify
484
- */
485
-
486
- /**
487
- * Create a new ASS style directly.
488
- * @param {ASS_Style} event
489
- */
490
- createStyle (style) {
491
- this.sendMessage('createStyle', { style })
492
- }
493
-
494
- /**
495
- * Overwrite the data of the style with the specified index.
496
- * @param {ASS_Style} event
497
- * @param {Number} index
498
- */
499
- setStyle (event, index) {
500
- this.sendMessage('setStyle', { event, index })
501
- }
502
-
503
- /**
504
- * Remove the style with the specified index.
505
- * @param {Number} index
506
- */
507
- removeStyle (index) {
508
- this.sendMessage('removeStyle', { index })
509
- }
510
-
511
- /**
512
- * Get all ASS styles.
513
- * @param {function(Error|null, ASS_Style)} callback Function to callback when worker returns the styles.
514
- */
515
- getStyles (callback) {
516
- this._fetchFromWorker({
517
- target: 'getStyles'
518
- }, (err, { styles }) => {
519
- callback(err, styles)
520
- })
521
- }
522
-
523
- /**
524
- * Adds a font to the renderer.
525
- * @param {String|Uint8Array} font Font to add.
526
- */
527
- addFont (font) {
528
- this.sendMessage('addFont', { font })
529
- }
530
-
531
- _sendLocalFont (name) {
532
- try {
533
- queryLocalFonts().then(fontData => {
534
- const font = fontData?.find(obj => obj.fullName.toLowerCase() === name)
535
- if (font) {
536
- font.blob().then(blob => {
537
- blob.arrayBuffer().then(buffer => {
538
- this.addFont(new Uint8Array(buffer))
539
- })
540
- })
541
- }
542
- })
543
- } catch (e) {
544
- console.warn('Local fonts API:', e)
545
- }
546
- }
547
-
548
- _getLocalFont ({ font }) {
549
- try {
550
- // electron by default has all permissions enabled, and it doesn't have perm query
551
- // if this happens, just send it
552
- if (navigator?.permissions?.query) {
553
- navigator.permissions.query({ name: 'local-fonts' }).then(permission => {
554
- if (permission.state === 'granted') {
555
- this._sendLocalFont(font)
556
- }
557
- })
558
- } else {
559
- this._sendLocalFont(font)
560
- }
561
- } catch (e) {
562
- console.warn('Local fonts API:', e)
563
- }
564
- }
565
-
566
- _unbusy () {
567
- // play catchup, leads to more frames being painted, but also more jitter
568
- if (this._lastDemandTime) {
569
- this._demandRender(this._lastDemandTime)
570
- } else {
571
- this.busy = false
572
- }
573
- }
574
-
575
- _handleRVFC (now, { mediaTime, width, height }) {
576
- if (this._destroyed) return null
577
- if (this.busy) {
578
- this._lastDemandTime = { mediaTime, width, height }
579
- } else {
580
- this.busy = true
581
- this._demandRender({ mediaTime, width, height })
582
- }
583
- this._video.requestVideoFrameCallback(this._handleRVFC.bind(this))
584
- }
585
-
586
- _demandRender ({ mediaTime, width, height }) {
587
- this._lastDemandTime = null
588
- if (width !== this._videoWidth || height !== this._videoHeight) {
589
- this._videoWidth = width
590
- this._videoHeight = height
591
- this.resize()
592
- }
593
- this.sendMessage('demand', { time: mediaTime + this.timeOffset })
594
- }
595
-
596
- // if we're using offscreen render, we can't use ctx filters, so we can't use a transfered canvas
597
- _detachOffscreen () {
598
- if (!this._offscreenRender || this._ctx) return null
599
- this._canvas.remove()
600
- this._createCanvas()
601
- this._canvasctrl = this._canvas
602
- this._ctx = this._canvasctrl.getContext('2d')
603
- this.sendMessage('detachOffscreen')
604
- // force a render after resize
605
- this.busy = false
606
- this.resize(0, 0, 0, 0, true)
607
- }
608
-
609
- // if the video or track changed, we need to re-attach the offscreen canvas
610
- _reAttachOffscreen () {
611
- if (!this._offscreenRender || !this._ctx) return null
612
- this._canvas.remove()
613
- this._createCanvas()
614
- this._canvasctrl = this._canvas.transferControlToOffscreen()
615
- this._ctx = false
616
- this.sendMessage('offscreenCanvas', null, [this._canvasctrl])
617
- this.resize(0, 0, 0, 0, true)
618
- }
619
-
620
- _updateColorSpace () {
621
- this._video.requestVideoFrameCallback(() => {
622
- try {
623
- // eslint-disable-next-line no-undef
624
- const frame = new VideoFrame(this._video)
625
- this._videoColorSpace = webYCbCrMap[frame.colorSpace.matrix]
626
- frame.close()
627
- this.sendMessage('getColorSpace')
628
- } catch (e) {
629
- // sources can be tainted
630
- console.warn(e)
631
- }
632
- })
633
- }
634
-
635
- /**
636
- * Veryify the color spaces for subtitles and videos, then apply filters to correct the color of subtitles.
637
- * @param {String} subtitleColorSpace Subtitle color space. One of: BT601 BT709 SMPTE240M FCC
638
- * @param {String} videoColorSpace Video color space. One of: BT601 BT709
639
- */
640
- _verifyColorSpace ({ subtitleColorSpace, videoColorSpace = this._videoColorSpace }) {
641
- if (!subtitleColorSpace || !videoColorSpace) return
642
- if (subtitleColorSpace === videoColorSpace) return
643
- this._detachOffscreen()
644
- this._ctx.filter = `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg'><filter id='f'><feColorMatrix type='matrix' values='${colorMatrixConversionMap[subtitleColorSpace][videoColorSpace]} 0 0 0 0 0 1 0'/></filter></svg>#f")`
645
- }
646
-
647
- _render ({ images, asyncRender, times, width, height, colorSpace }) {
648
- this._unbusy()
649
- if (this.debug) times.IPCTime = Date.now() - times.JSRenderTime
650
- if (this._canvasctrl.width !== width || this._canvasctrl.height !== height) {
651
- this._canvasctrl.width = width
652
- this._canvasctrl.height = height
653
- this._verifyColorSpace({ subtitleColorSpace: colorSpace })
654
- }
655
- this._ctx.clearRect(0, 0, this._canvasctrl.width, this._canvasctrl.height)
656
- for (const image of images) {
657
- if (image.image) {
658
- if (asyncRender) {
659
- this._ctx.drawImage(image.image, image.x, image.y)
660
- image.image.close()
661
- } else {
662
- this._bufferCanvas.width = image.w
663
- this._bufferCanvas.height = image.h
664
- this._bufferCtx.putImageData(new ImageData(this._fixAlpha(new Uint8ClampedArray(image.image)), image.w, image.h), 0, 0)
665
- this._ctx.drawImage(this._bufferCanvas, image.x, image.y)
666
- }
667
- }
668
- }
669
- if (this.debug) {
670
- times.JSRenderTime = Date.now() - times.JSRenderTime - times.IPCTime
671
- let total = 0
672
- const count = times.bitmaps || images.length
673
- delete times.bitmaps
674
- for (const key in times) total += times[key]
675
- console.log('Bitmaps: ' + count + ' Total: ' + (total | 0) + 'ms', times)
676
- }
677
- }
678
-
679
- _fixAlpha (uint8) {
680
- if (JASSUB._hasAlphaBug) {
681
- for (let j = 3; j < uint8.length; j += 4) {
682
- uint8[j] = uint8[j] > 1 ? uint8[j] : 1
683
- }
684
- }
685
- return uint8
686
- }
687
-
688
- _ready () {
689
- this._init()
690
- this.dispatchEvent(new CustomEvent('ready'))
691
- }
692
-
693
- /**
694
- * Send data and execute function in the worker.
695
- * @param {String} target Target function.
696
- * @param {Object} [data] Data for function.
697
- * @param {Transferable[]} [transferable] Array of transferables.
698
- */
699
- async sendMessage (target, data = {}, transferable) {
700
- await this._loaded
701
- if (transferable) {
702
- this._worker.postMessage({
703
- target,
704
- transferable,
705
- ...data
706
- }, [...transferable])
707
- } else {
708
- this._worker.postMessage({
709
- target,
710
- ...data
711
- })
712
- }
713
- }
714
-
715
- _fetchFromWorker (workerOptions, callback) {
716
- try {
717
- const target = workerOptions.target
718
-
719
- const timeout = setTimeout(() => {
720
- reject(new Error('Error: Timeout while try to fetch ' + target))
721
- }, 5000)
722
-
723
- const resolve = ({ data }) => {
724
- if (data.target === target) {
725
- callback(null, data)
726
- this._worker.removeEventListener('message', resolve)
727
- this._worker.removeEventListener('error', reject)
728
- clearTimeout(timeout)
729
- }
730
- }
731
-
732
- const reject = event => {
733
- callback(event)
734
- this._worker.removeEventListener('message', resolve)
735
- this._worker.removeEventListener('error', reject)
736
- clearTimeout(timeout)
737
- }
738
-
739
- this._worker.addEventListener('message', resolve)
740
- this._worker.addEventListener('error', reject)
741
-
742
- this._worker.postMessage(workerOptions)
743
- } catch (error) {
744
- this._error(error)
745
- }
746
- }
747
-
748
- _console ({ content, command }) {
749
- console[command].apply(console, JSON.parse(content))
750
- }
751
-
752
- _onmessage ({ data }) {
753
- if (this['_' + data.target]) this['_' + data.target](data)
754
- }
755
-
756
- _error (err) {
757
- const error = err instanceof Error
758
- ? err // pass
759
- : err instanceof ErrorEvent
760
- ? err.error // ErrorEvent has error property which is an Error object
761
- : new Error(err) // construct Error
762
-
763
- const event = err instanceof Event
764
- ? new ErrorEvent(err.type, err) // clone event
765
- : new ErrorEvent('error', { error }) // construct Event
766
-
767
- this.dispatchEvent(event)
768
-
769
- console.error(error)
770
- }
771
-
772
- _removeListeners () {
773
- if (this._video) {
774
- if (this._ro) this._ro.unobserve(this._video)
775
- if (this._ctx) this._ctx.filter = 'none'
776
- this._video.removeEventListener('timeupdate', this._boundTimeUpdate)
777
- this._video.removeEventListener('progress', this._boundTimeUpdate)
778
- this._video.removeEventListener('waiting', this._boundTimeUpdate)
779
- this._video.removeEventListener('seeking', this._boundTimeUpdate)
780
- this._video.removeEventListener('playing', this._boundTimeUpdate)
781
- this._video.removeEventListener('ratechange', this._boundSetRate)
782
- this._video.removeEventListener('resize', this._boundResize)
783
- this._video.removeEventListener('loadedmetadata', this._boundUpdateColorSpace)
784
- }
785
- }
786
-
787
- /**
788
- * Destroy the object, worker, listeners and all data.
789
- * @param {String} [err] Error to throw when destroying.
790
- */
791
- destroy (err) {
792
- if (err) this._error(err)
793
- if (this._video && this._canvasParent) this._video.parentNode.removeChild(this._canvasParent)
794
- this._destroyed = true
795
- this._removeListeners()
796
- this.sendMessage('destroy')
797
- this._worker.terminate()
798
- }
799
- }
1
+ import 'rvfc-polyfill'
2
+
3
+ const webYCbCrMap = {
4
+ bt709: 'BT709',
5
+ // these might not be exactly correct? oops?
6
+ bt470bg: 'BT601', // alias BT.601 PAL... whats the difference?
7
+ smpte170m: 'BT601'// alias BT.601 NTSC... whats the difference?
8
+ }
9
+
10
+ const colorMatrixConversionMap = {
11
+ BT601: {
12
+ BT709: '1.0863 -0.0723 -0.014 0 0 0.0965 0.8451 0.0584 0 0 -0.0141 -0.0277 1.0418'
13
+ },
14
+ BT709: {
15
+ BT601: '0.9137 0.0784 0.0079 0 0 -0.1049 1.1722 -0.0671 0 0 0.0096 0.0322 0.9582'
16
+ },
17
+ FCC: {
18
+ BT709: '1.0873 -0.0736 -0.0137 0 0 0.0974 0.8494 0.0531 0 0 -0.0127 -0.0251 1.0378',
19
+ BT601: '1.001 -0.0008 -0.0002 0 0 0.0009 1.005 -0.006 0 0 0.0013 0.0027 0.996'
20
+ },
21
+ SMPTE240M: {
22
+ BT709: '0.9993 0.0006 0.0001 0 0 -0.0004 0.9812 0.0192 0 0 -0.0034 -0.0114 1.0148',
23
+ BT601: '0.913 0.0774 0.0096 0 0 -0.1051 1.1508 -0.0456 0 0 0.0063 0.0207 0.973'
24
+ }
25
+ }
26
+
27
+ /**
28
+ * New JASSUB instance.
29
+ * @class
30
+ */
31
+ export default class JASSUB extends EventTarget {
32
+ /**
33
+ * @param {Object} options Settings object.
34
+ * @param {HTMLVideoElement} options.video Video to use as target for rendering and event listeners. Optional if canvas is specified instead.
35
+ * @param {HTMLCanvasElement} [options.canvas=HTMLCanvasElement] Canvas to use for manual handling. Not required if video is specified.
36
+ * @param {'js'|'wasm'} [options.blendMode='js'] Which image blending mode to use. WASM will perform better on lower end devices, JS will perform better if the device and browser supports hardware acceleration.
37
+ * @param {Boolean} [options.asyncRender=true] Whether or not to use async rendering, which offloads the CPU by creating image bitmaps on the GPU.
38
+ * @param {Boolean} [options.offscreenRender=true] Whether or not to render things fully on the worker, greatly reduces CPU usage.
39
+ * @param {Boolean} [options.onDemandRender=true] Whether or not to render subtitles as the video player decodes renders, rather than predicting which frame the player is on using events.
40
+ * @param {Number} [options.targetFps=24] Target FPS to render subtitles at. Ignored when onDemandRender is enabled.
41
+ * @param {Number} [options.timeOffset=0] Subtitle time offset in seconds.
42
+ * @param {Boolean} [options.debug=false] Whether or not to print debug information.
43
+ * @param {Number} [options.prescaleFactor=1.0] Scale down (< 1.0) the subtitles canvas to improve performance at the expense of quality, or scale it up (> 1.0).
44
+ * @param {Number} [options.prescaleHeightLimit=1080] The height in pixels beyond which the subtitles canvas won't be prescaled.
45
+ * @param {Number} [options.maxRenderHeight=0] The maximum rendering height in pixels of the subtitles canvas. Beyond this subtitles will be upscaled by the browser.
46
+ * @param {Boolean} [options.dropAllAnimations=false] Attempt to discard all animated tags. Enabling this may severly mangle complex subtitles and should only be considered as an last ditch effort of uncertain success for hardware otherwise incapable of displaing anything. Will not reliably work with manually edited or allocated events.
47
+ * @param {Boolean} [options.dropAllBlur=false] The holy grail of performance gains. If heavy TS lags a lot, disabling this will make it ~x10 faster. This drops blur from all added subtitle tracks making most text and backgrounds look sharper, this is way less intrusive than dropping all animations, while still offering major performance gains.
48
+ * @param {String} [options.workerUrl='jassub-worker.js'] The URL of the worker.
49
+ * @param {String} [options.wasmUrl='jassub-worker.wasm'] The URL of the worker WASM.
50
+ * @param {String} [options.legacyWasmUrl='jassub-worker.wasm.js'] The URL of the worker WASM. Only loaded if the browser doesn't support WASM.
51
+ * @param {String} options.modernWasmUrl The URL of the modern worker WASM. This includes faster ASM instructions, but is only supported by newer browsers, disabled if the URL isn't defined.
52
+ * @param {String} [options.subUrl=options.subContent] The URL of the subtitle file to play.
53
+ * @param {String} [options.subContent=options.subUrl] The content of the subtitle file to play.
54
+ * @param {String[]|Uint8Array[]} [options.fonts] An array of links or Uint8Arrays to the fonts used in the subtitle. If Uint8Array is used the array is copied, not referenced. This forces all the fonts in this array to be loaded by the renderer, regardless of if they are used.
55
+ * @param {Object} [options.availableFonts={'liberation sans': './default.woff2'}] Object with all available fonts - Key is font family in lower case, value is link or Uint8Array: { arial: '/font1.ttf' }. These fonts are selectively loaded if detected as used in the current subtitle track.
56
+ * @param {String} [options.fallbackFont='liberation sans'] The font family key of the fallback font in availableFonts to use if the other font for the style is missing special glyphs or unicode.
57
+ * @param {Boolean} [options.useLocalFonts=false] If the Local Font Access API is enabled [chrome://flags/#font-access], the library will query for permissions to use local fonts and use them if any are missing. The permission can be queried beforehand using navigator.permissions.request({ name: 'local-fonts' }).
58
+ * @param {Number} [options.libassMemoryLimit] libass bitmap cache memory limit in MiB (approximate).
59
+ * @param {Number} [options.libassGlyphLimit] libass glyph cache memory limit in MiB (approximate).
60
+ */
61
+ constructor (options) {
62
+ super()
63
+ if (!globalThis.Worker) throw this.destroy('Worker not supported')
64
+ if (!options) throw this.destroy('No options provided')
65
+
66
+ this._loaded = /** @type {Promise<void>} */(new Promise(resolve => {
67
+ this._init = resolve
68
+ }))
69
+
70
+ const test = JASSUB._test()
71
+ this._onDemandRender = 'requestVideoFrameCallback' in HTMLVideoElement.prototype && (options.onDemandRender ?? true)
72
+
73
+ // don't support offscreen rendering on custom canvases, as we can't replace it if colorSpace doesn't match
74
+ this._offscreenRender = 'transferControlToOffscreen' in HTMLCanvasElement.prototype && !options.canvas && (options.offscreenRender ?? true)
75
+
76
+ this.timeOffset = options.timeOffset || 0
77
+ this._video = options.video
78
+ this._videoHeight = 0
79
+ this._videoWidth = 0
80
+ this._videoColorSpace = null
81
+ this._canvas = options.canvas
82
+ if (this._video && !this._canvas) {
83
+ this._canvasParent = document.createElement('div')
84
+ this._canvasParent.className = 'JASSUB'
85
+ this._canvasParent.style.position = 'relative'
86
+
87
+ this._canvas = this._createCanvas()
88
+
89
+ if (this._video.nextSibling) {
90
+ this._video.parentNode.insertBefore(this._canvasParent, this._video.nextSibling)
91
+ } else {
92
+ this._video.parentNode.appendChild(this._canvasParent)
93
+ }
94
+ } else if (!this._canvas) {
95
+ throw this.destroy('Don\'t know where to render: you should give video or canvas in options.')
96
+ }
97
+
98
+ this._bufferCanvas = document.createElement('canvas')
99
+ this._bufferCtx = this._bufferCanvas.getContext('2d')
100
+ if (!this._bufferCtx) throw this.destroy('Canvas rendering not supported')
101
+
102
+ this._canvasctrl = this._offscreenRender ? this._canvas.transferControlToOffscreen() : this._canvas
103
+ this._ctx = !this._offscreenRender && this._canvasctrl.getContext('2d')
104
+
105
+ this._lastRenderTime = 0
106
+ this.debug = !!options.debug
107
+
108
+ this.prescaleFactor = options.prescaleFactor || 1.0
109
+ this.prescaleHeightLimit = options.prescaleHeightLimit || 1080
110
+ this.maxRenderHeight = options.maxRenderHeight || 0 // 0 - no limit.
111
+
112
+ this._boundResize = this.resize.bind(this)
113
+ this._boundTimeUpdate = this._timeupdate.bind(this)
114
+ this._boundSetRate = this.setRate.bind(this)
115
+ this._boundUpdateColorSpace = this._updateColorSpace.bind(this)
116
+ if (this._video) this.setVideo(options.video)
117
+
118
+ if (this._onDemandRender) {
119
+ this.busy = false
120
+ this._lastDemandTime = null
121
+ }
122
+
123
+ this._worker = new Worker(options.workerUrl || 'jassub-worker.js')
124
+ this._worker.onmessage = e => this._onmessage(e)
125
+ this._worker.onerror = e => this._error(e)
126
+
127
+ test.then(() => {
128
+ this._worker.postMessage({
129
+ target: 'init',
130
+ wasmUrl: JASSUB._supportsSIMD && options.modernWasmUrl ? options.modernWasmUrl : options.wasmUrl || 'jassub-worker.wasm',
131
+ legacyWasmUrl: options.legacyWasmUrl || 'jassub-worker.wasm.js',
132
+ asyncRender: typeof createImageBitmap !== 'undefined' && (options.asyncRender ?? true),
133
+ onDemandRender: this._onDemandRender,
134
+ width: this._canvasctrl.width || 0,
135
+ height: this._canvasctrl.height || 0,
136
+ blendMode: options.blendMode || 'js',
137
+ subUrl: options.subUrl,
138
+ subContent: options.subContent || null,
139
+ fonts: options.fonts || [],
140
+ availableFonts: options.availableFonts || { 'liberation sans': './default.woff2' },
141
+ fallbackFont: options.fallbackFont || 'liberation sans',
142
+ debug: this.debug,
143
+ targetFps: options.targetFps || 24,
144
+ dropAllAnimations: options.dropAllAnimations,
145
+ dropAllBlur: options.dropAllBlur,
146
+ libassMemoryLimit: options.libassMemoryLimit || 0,
147
+ libassGlyphLimit: options.libassGlyphLimit || 0,
148
+ // @ts-ignore
149
+ useLocalFonts: typeof queryLocalFonts !== 'undefined' && (options.useLocalFonts ?? true),
150
+ hasBitmapBug: JASSUB._hasBitmapBug
151
+ })
152
+ if (this._offscreenRender === true) this.sendMessage('offscreenCanvas', null, [this._canvasctrl])
153
+ })
154
+ }
155
+
156
+ _createCanvas () {
157
+ this._canvas = document.createElement('canvas')
158
+ this._canvas.style.display = 'block'
159
+ this._canvas.style.position = 'absolute'
160
+ this._canvas.style.pointerEvents = 'none'
161
+ this._canvasParent.appendChild(this._canvas)
162
+ return this._canvas
163
+ }
164
+
165
+ // test support for WASM, ImageData, alphaBug, but only once, on init so it doesn't run when first running the page
166
+
167
+ /** @type {boolean|null} */
168
+ static _supportsSIMD = null
169
+ /** @type {boolean|null} */
170
+ static _hasAlphaBug = null
171
+ /** @type {boolean|null} */
172
+ static _hasBitmapBug = null
173
+
174
+ static async _test () {
175
+ // check if ran previously
176
+ if (JASSUB._hasBitmapBug !== null) return null
177
+
178
+ try {
179
+ JASSUB._supportsSIMD = WebAssembly.validate(Uint8Array.of(0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 123, 3, 2, 1, 0, 10, 10, 1, 8, 0, 65, 0, 253, 15, 253, 98, 11))
180
+ } catch (e) {
181
+ JASSUB._supportsSIMD = false
182
+ }
183
+
184
+ const canvas1 = document.createElement('canvas')
185
+ const ctx1 = canvas1.getContext('2d', { willReadFrequently: true })
186
+ if (!ctx1) throw new Error('Canvas rendering not supported')
187
+ // test ImageData constructor
188
+ if (typeof ImageData.prototype.constructor === 'function') {
189
+ try {
190
+ // try actually calling ImageData, as on some browsers it's reported
191
+ // as existing but calling it errors out as "TypeError: Illegal constructor"
192
+ // eslint-disable-next-line no-new
193
+ new ImageData(new Uint8ClampedArray([0, 0, 0, 0]), 1, 1)
194
+ } catch (e) {
195
+ console.log('Detected that ImageData is not constructable despite browser saying so')
196
+
197
+ // @ts-ignore
198
+ self.ImageData = function (data, width, height) {
199
+ const imageData = ctx1.createImageData(width, height)
200
+ if (data) imageData.data.set(data)
201
+ return imageData
202
+ }
203
+ }
204
+ }
205
+
206
+ // Test for alpha bug, where e.g. WebKit can render a transparent pixel
207
+ // (with alpha == 0) as non-black which then leads to visual artifacts.
208
+ const canvas2 = document.createElement('canvas')
209
+ const ctx2 = canvas2.getContext('2d', { willReadFrequently: true })
210
+ if (!ctx2) throw new Error('Canvas rendering not supported')
211
+
212
+ canvas1.width = canvas2.width = 1
213
+ canvas1.height = canvas2.height = 1
214
+ ctx1.clearRect(0, 0, 1, 1)
215
+ ctx2.clearRect(0, 0, 1, 1)
216
+ const prePut = ctx2.getImageData(0, 0, 1, 1).data
217
+ ctx1.putImageData(new ImageData(new Uint8ClampedArray([0, 255, 0, 0]), 1, 1), 0, 0)
218
+ ctx2.drawImage(canvas1, 0, 0)
219
+ const postPut = ctx2.getImageData(0, 0, 1, 1).data
220
+ JASSUB._hasAlphaBug = prePut[1] !== postPut[1]
221
+ if (JASSUB._hasAlphaBug) console.log('Detected a browser having issue with transparent pixels, applying workaround')
222
+
223
+ if (typeof createImageBitmap !== 'undefined') {
224
+ const subarray = new Uint8ClampedArray([255, 0, 255, 0, 255]).subarray(1, 5)
225
+ ctx2.drawImage(await createImageBitmap(new ImageData(subarray, 1)), 0, 0)
226
+ const { data } = ctx2.getImageData(0, 0, 1, 1)
227
+ JASSUB._hasBitmapBug = false
228
+ for (const [i, number] of data.entries()) {
229
+ // realistically at most this will be a diff of 4, but just to be safe
230
+ if (Math.abs(subarray[i] - number) > 15) {
231
+ JASSUB._hasBitmapBug = true
232
+ console.log('Detected a browser having issue with partial bitmaps, applying workaround')
233
+ break
234
+ }
235
+ }
236
+ } else {
237
+ JASSUB._hasBitmapBug = false
238
+ }
239
+
240
+ canvas1.remove()
241
+ canvas2.remove()
242
+ }
243
+
244
+ /**
245
+ * Resize the canvas to given parameters. Auto-generated if values are ommited.
246
+ * @param {Number} [width=0]
247
+ * @param {Number} [height=0]
248
+ * @param {Number} [top=0]
249
+ * @param {Number} [left=0]
250
+ * @param {Boolean} [force=false]
251
+ */
252
+ resize (width = 0, height = 0, top = 0, left = 0, force = this._video?.paused) {
253
+ if ((!width || !height) && this._video) {
254
+ const videoSize = this._getVideoPosition()
255
+ let renderSize = null
256
+ // support anamorphic video
257
+ if (this._videoWidth) {
258
+ const widthRatio = this._video.videoWidth / this._videoWidth
259
+ const heightRatio = this._video.videoHeight / this._videoHeight
260
+ renderSize = this._computeCanvasSize((videoSize.width || 0) / widthRatio, (videoSize.height || 0) / heightRatio)
261
+ } else {
262
+ renderSize = this._computeCanvasSize(videoSize.width || 0, videoSize.height || 0)
263
+ }
264
+ width = renderSize.width
265
+ height = renderSize.height
266
+ if (this._canvasParent) {
267
+ top = videoSize.y - (this._canvasParent.getBoundingClientRect().top - this._video.getBoundingClientRect().top)
268
+ left = videoSize.x
269
+ }
270
+ this._canvas.style.width = videoSize.width + 'px'
271
+ this._canvas.style.height = videoSize.height + 'px'
272
+ }
273
+
274
+ this._canvas.style.top = top + 'px'
275
+ this._canvas.style.left = left + 'px'
276
+ if (force && this.busy === false) {
277
+ this.busy = true
278
+ } else {
279
+ force = false
280
+ }
281
+ this.sendMessage('canvas', { width, height, force })
282
+ }
283
+
284
+ _getVideoPosition (width = this._video.videoWidth, height = this._video.videoHeight) {
285
+ const videoRatio = width / height
286
+ const { offsetWidth, offsetHeight } = this._video
287
+ const elementRatio = offsetWidth / offsetHeight
288
+ width = offsetWidth
289
+ height = offsetHeight
290
+ if (elementRatio > videoRatio) {
291
+ width = Math.floor(offsetHeight * videoRatio)
292
+ } else {
293
+ height = Math.floor(offsetWidth / videoRatio)
294
+ }
295
+
296
+ const x = (offsetWidth - width) / 2
297
+ const y = (offsetHeight - height) / 2
298
+
299
+ return { width, height, x, y }
300
+ }
301
+
302
+ _computeCanvasSize (width = 0, height = 0) {
303
+ const scalefactor = this.prescaleFactor <= 0 ? 1.0 : this.prescaleFactor
304
+ const ratio = self.devicePixelRatio || 1
305
+
306
+ width = width * ratio
307
+ height = height * ratio
308
+ if (height <= 0 || width <= 0) {
309
+ width = 0
310
+ height = 0
311
+ } else {
312
+ const sgn = scalefactor < 1 ? -1 : 1
313
+ let newH = height * ratio
314
+ if (sgn * newH * scalefactor <= sgn * this.prescaleHeightLimit) {
315
+ newH *= scalefactor
316
+ } else if (sgn * newH < sgn * this.prescaleHeightLimit) {
317
+ newH = this.prescaleHeightLimit
318
+ }
319
+
320
+ if (this.maxRenderHeight > 0 && newH > this.maxRenderHeight) newH = this.maxRenderHeight
321
+
322
+ width *= newH / height
323
+ height = newH
324
+ }
325
+
326
+ return { width, height }
327
+ }
328
+
329
+ _timeupdate ({ type }) {
330
+ const eventmap = {
331
+ seeking: true,
332
+ waiting: true,
333
+ playing: false
334
+ }
335
+ const playing = eventmap[type]
336
+ if (playing != null) this._playstate = playing
337
+ this.setCurrentTime(this._video.paused || this._playstate, this._video.currentTime + this.timeOffset)
338
+ }
339
+
340
+ /**
341
+ * Change the video to use as target for event listeners.
342
+ * @param {HTMLVideoElement} video
343
+ */
344
+ setVideo (video) {
345
+ if (video instanceof HTMLVideoElement) {
346
+ this._removeListeners()
347
+ this._video = video
348
+ if (this._onDemandRender) {
349
+ this._video.requestVideoFrameCallback(this._handleRVFC.bind(this))
350
+ } else {
351
+ this._playstate = video.paused
352
+
353
+ video.addEventListener('timeupdate', this._boundTimeUpdate, false)
354
+ video.addEventListener('progress', this._boundTimeUpdate, false)
355
+ video.addEventListener('waiting', this._boundTimeUpdate, false)
356
+ video.addEventListener('seeking', this._boundTimeUpdate, false)
357
+ video.addEventListener('playing', this._boundTimeUpdate, false)
358
+ video.addEventListener('ratechange', this._boundSetRate, false)
359
+ video.addEventListener('resize', this._boundResize, false)
360
+ }
361
+ // everything else is unreliable for this, loadedmetadata and loadeddata included.
362
+ if ('VideoFrame' in window) {
363
+ video.addEventListener('loadedmetadata', this._boundUpdateColorSpace, false)
364
+ if (video.readyState > 2) this._updateColorSpace()
365
+ }
366
+ if (video.videoWidth > 0) this.resize()
367
+ // Support Element Resize Observer
368
+ if (typeof ResizeObserver !== 'undefined') {
369
+ if (!this._ro) this._ro = new ResizeObserver(() => this.resize())
370
+ this._ro.observe(video)
371
+ }
372
+ } else {
373
+ this._error('Video element invalid!')
374
+ }
375
+ }
376
+
377
+ runBenchmark () {
378
+ this.sendMessage('runBenchmark')
379
+ }
380
+
381
+ /**
382
+ * Overwrites the current subtitle content.
383
+ * @param {String} url URL to load subtitles from.
384
+ */
385
+ setTrackByUrl (url) {
386
+ this.sendMessage('setTrackByUrl', { url })
387
+ this._reAttachOffscreen()
388
+ if (this._ctx) this._ctx.filter = 'none'
389
+ }
390
+
391
+ /**
392
+ * Overwrites the current subtitle content.
393
+ * @param {String} content Content of the ASS file.
394
+ */
395
+ setTrack (content) {
396
+ this.sendMessage('setTrack', { content })
397
+ this._reAttachOffscreen()
398
+ if (this._ctx) this._ctx.filter = 'none'
399
+ }
400
+
401
+ /**
402
+ * Free currently used subtitle track.
403
+ */
404
+ freeTrack () {
405
+ this.sendMessage('freeTrack')
406
+ }
407
+
408
+ /**
409
+ * Sets the playback state of the media.
410
+ * @param {Boolean} isPaused Pause/Play subtitle playback.
411
+ */
412
+ setIsPaused (isPaused) {
413
+ this.sendMessage('video', { isPaused })
414
+ }
415
+
416
+ /**
417
+ * Sets the playback rate of the media [speed multiplier].
418
+ * @param {Number} rate Playback rate.
419
+ */
420
+ setRate (rate) {
421
+ this.sendMessage('video', { rate })
422
+ }
423
+
424
+ /**
425
+ * Sets the current time, playback state and rate of the subtitles.
426
+ * @param {Boolean} [isPaused] Pause/Play subtitle playback.
427
+ * @param {Number} [currentTime] Time in seconds.
428
+ * @param {Number} [rate] Playback rate.
429
+ */
430
+ setCurrentTime (isPaused, currentTime, rate) {
431
+ this.sendMessage('video', { isPaused, currentTime, rate, colorSpace: this._videoColorSpace })
432
+ }
433
+
434
+ /**
435
+ * @typedef {Object} ASS_Event
436
+ * @property {Number} Start Start Time of the Event, in 0:00:00:00 format ie. Hrs:Mins:Secs:hundredths. This is the time elapsed during script playback at which the text will appear onscreen. Note that there is a single digit for the hours!
437
+ * @property {Number} Duration End Time of the Event, in 0:00:00:00 format ie. Hrs:Mins:Secs:hundredths. This is the time elapsed during script playback at which the text will disappear offscreen. Note that there is a single digit for the hours!
438
+ * @property {String} Style Style name. If it is "Default", then your own *Default style will be subtituted.
439
+ * @property {String} Name Character name. This is the name of the character who speaks the dialogue. It is for information only, to make the script is easier to follow when editing/timing.
440
+ * @property {Number} MarginL 4-figure Left Margin override. The values are in pixels. All zeroes means the default margins defined by the style are used.
441
+ * @property {Number} MarginR 4-figure Right Margin override. The values are in pixels. All zeroes means the default margins defined by the style are used.
442
+ * @property {Number} MarginV 4-figure Bottom Margin override. The values are in pixels. All zeroes means the default margins defined by the style are used.
443
+ * @property {String} Effect Transition Effect. This is either empty, or contains information for one of the three transition effects implemented in SSA v4.x
444
+ * @property {String} Text Subtitle Text. This is the actual text which will be displayed as a subtitle onscreen. Everything after the 9th comma is treated as the subtitle text, so it can include commas.
445
+ * @property {Number} ReadOrder Number in order of which to read this event.
446
+ * @property {Number} Layer Z-index overlap in which to render this event.
447
+ * @property {Number} _index (Internal) index of the event.
448
+ */
449
+
450
+ /**
451
+ * Create a new ASS event directly.
452
+ * @param {ASS_Event} event
453
+ */
454
+ createEvent (event) {
455
+ this.sendMessage('createEvent', { event })
456
+ }
457
+
458
+ /**
459
+ * Overwrite the data of the event with the specified index.
460
+ * @param {ASS_Event} event
461
+ * @param {Number} index
462
+ */
463
+ setEvent (event, index) {
464
+ this.sendMessage('setEvent', { event, index })
465
+ }
466
+
467
+ /**
468
+ * Remove the event with the specified index.
469
+ * @param {Number} index
470
+ */
471
+ removeEvent (index) {
472
+ this.sendMessage('removeEvent', { index })
473
+ }
474
+
475
+ /**
476
+ * Get all ASS events.
477
+ * @param {function(Error|null, ASS_Event): void} callback Function to callback when worker returns the events.
478
+ */
479
+ getEvents (callback) {
480
+ this._fetchFromWorker({
481
+ target: 'getEvents'
482
+ }, (err, { events }) => {
483
+ callback(err, events)
484
+ })
485
+ }
486
+
487
+ /**
488
+ * @typedef {Object} ASS_Style
489
+ * @property {String} Name The name of the Style. Case sensitive. Cannot include commas.
490
+ * @property {String} FontName The fontname as used by Windows. Case-sensitive.
491
+ * @property {Number} FontSize Font size.
492
+ * @property {Number} PrimaryColour A long integer BGR (blue-green-red) value. ie. the byte order in the hexadecimal equivelent of this number is BBGGRR
493
+ * @property {Number} SecondaryColour A long integer BGR (blue-green-red) value. ie. the byte order in the hexadecimal equivelent of this number is BBGGRR
494
+ * @property {Number} OutlineColour A long integer BGR (blue-green-red) value. ie. the byte order in the hexadecimal equivelent of this number is BBGGRR
495
+ * @property {Number} BackColour This is the colour of the subtitle outline or shadow, if these are used. A long integer BGR (blue-green-red) value. ie. the byte order in the hexadecimal equivelent of this number is BBGGRR.
496
+ * @property {Number} Bold This defines whether text is bold (true) or not (false). -1 is True, 0 is False. This is independant of the Italic attribute - you can have have text which is both bold and italic.
497
+ * @property {Number} Italic Italic. This defines whether text is italic (true) or not (false). -1 is True, 0 is False. This is independant of the bold attribute - you can have have text which is both bold and italic.
498
+ * @property {Number} Underline -1 or 0
499
+ * @property {Number} StrikeOut -1 or 0
500
+ * @property {Number} ScaleX Modifies the width of the font. [percent]
501
+ * @property {Number} ScaleY Modifies the height of the font. [percent]
502
+ * @property {Number} Spacing Extra space between characters. [pixels]
503
+ * @property {Number} Angle The origin of the rotation is defined by the alignment. Can be a floating point number. [degrees]
504
+ * @property {Number} BorderStyle 1=Outline + drop shadow, 3=Opaque box
505
+ * @property {Number} Outline If BorderStyle is 1, then this specifies the width of the outline around the text, in pixels. Values may be 0, 1, 2, 3 or 4.
506
+ * @property {Number} Shadow If BorderStyle is 1, then this specifies the depth of the drop shadow behind the text, in pixels. Values may be 0, 1, 2, 3 or 4. Drop shadow is always used in addition to an outline - SSA will force an outline of 1 pixel if no outline width is given.
507
+ * @property {Number} Alignment This sets how text is "justified" within the Left/Right onscreen margins, and also the vertical placing. Values may be 1=Left, 2=Centered, 3=Right. Add 4 to the value for a "Toptitle". Add 8 to the value for a "Midtitle". eg. 5 = left-justified toptitle
508
+ * @property {Number} MarginL This defines the Left Margin in pixels. It is the distance from the left-hand edge of the screen.The three onscreen margins (MarginL, MarginR, MarginV) define areas in which the subtitle text will be displayed.
509
+ * @property {Number} MarginR This defines the Right Margin in pixels. It is the distance from the right-hand edge of the screen. The three onscreen margins (MarginL, MarginR, MarginV) define areas in which the subtitle text will be displayed.
510
+ * @property {Number} MarginV This defines the vertical Left Margin in pixels. For a subtitle, it is the distance from the bottom of the screen. For a toptitle, it is the distance from the top of the screen. For a midtitle, the value is ignored - the text will be vertically centred.
511
+ * @property {Number} Encoding This specifies the font character set or encoding and on multi-lingual Windows installations it provides access to characters used in multiple than one languages. It is usually 0 (zero) for English (Western, ANSI) Windows.
512
+ * @property {Number} treat_fontname_as_pattern
513
+ * @property {Number} Blur
514
+ * @property {Number} Justify
515
+ */
516
+
517
+ /**
518
+ * Create a new ASS style directly.
519
+ * @param {ASS_Style} style
520
+ */
521
+ createStyle (style) {
522
+ this.sendMessage('createStyle', { style })
523
+ }
524
+
525
+ /**
526
+ * Overwrite the data of the style with the specified index.
527
+ * @param {ASS_Style} event
528
+ * @param {Number} index
529
+ */
530
+ setStyle (event, index) {
531
+ this.sendMessage('setStyle', { event, index })
532
+ }
533
+
534
+ /**
535
+ * Remove the style with the specified index.
536
+ * @param {Number} index
537
+ */
538
+ removeStyle (index) {
539
+ this.sendMessage('removeStyle', { index })
540
+ }
541
+
542
+ /**
543
+ * Get all ASS styles.
544
+ * @param {function(Error|null, ASS_Style): void} callback Function to callback when worker returns the styles.
545
+ */
546
+ getStyles (callback) {
547
+ this._fetchFromWorker({
548
+ target: 'getStyles'
549
+ }, (err, { styles }) => {
550
+ callback(err, styles)
551
+ })
552
+ }
553
+
554
+ /**
555
+ * Adds a font to the renderer.
556
+ * @param {String|Uint8Array} font Font to add.
557
+ */
558
+ addFont (font) {
559
+ this.sendMessage('addFont', { font })
560
+ }
561
+
562
+ _sendLocalFont (name) {
563
+ try {
564
+ // @ts-ignore
565
+ queryLocalFonts().then(fontData => {
566
+ const font = fontData?.find(obj => obj.fullName.toLowerCase() === name)
567
+ if (font) {
568
+ font.blob().then(blob => {
569
+ blob.arrayBuffer().then(buffer => {
570
+ this.addFont(new Uint8Array(buffer))
571
+ })
572
+ })
573
+ }
574
+ })
575
+ } catch (e) {
576
+ console.warn('Local fonts API:', e)
577
+ }
578
+ }
579
+
580
+ _getLocalFont ({ font }) {
581
+ try {
582
+ // electron by default has all permissions enabled, and it doesn't have perm query
583
+ // if this happens, just send it
584
+ if (navigator?.permissions?.query) {
585
+ // @ts-ignore
586
+ navigator.permissions.query({ name: 'local-fonts' }).then(permission => {
587
+ if (permission.state === 'granted') {
588
+ this._sendLocalFont(font)
589
+ }
590
+ })
591
+ } else {
592
+ this._sendLocalFont(font)
593
+ }
594
+ } catch (e) {
595
+ console.warn('Local fonts API:', e)
596
+ }
597
+ }
598
+
599
+ _unbusy () {
600
+ // play catchup, leads to more frames being painted, but also more jitter
601
+ if (this._lastDemandTime) {
602
+ this._demandRender(this._lastDemandTime)
603
+ } else {
604
+ this.busy = false
605
+ }
606
+ }
607
+
608
+ _handleRVFC (now, { mediaTime, width, height }) {
609
+ if (this._destroyed) return null
610
+ if (this.busy) {
611
+ this._lastDemandTime = { mediaTime, width, height }
612
+ } else {
613
+ this.busy = true
614
+ this._demandRender({ mediaTime, width, height })
615
+ }
616
+ this._video.requestVideoFrameCallback(this._handleRVFC.bind(this))
617
+ }
618
+
619
+ _demandRender ({ mediaTime, width, height }) {
620
+ this._lastDemandTime = null
621
+ if (width !== this._videoWidth || height !== this._videoHeight) {
622
+ this._videoWidth = width
623
+ this._videoHeight = height
624
+ this.resize()
625
+ }
626
+ this.sendMessage('demand', { time: mediaTime + this.timeOffset })
627
+ }
628
+
629
+ // if we're using offscreen render, we can't use ctx filters, so we can't use a transfered canvas
630
+ _detachOffscreen () {
631
+ if (!this._offscreenRender || this._ctx) return null
632
+ this._canvas.remove()
633
+ this._createCanvas()
634
+ this._canvasctrl = this._canvas
635
+ this._ctx = this._canvasctrl.getContext('2d')
636
+ this.sendMessage('detachOffscreen')
637
+ // force a render after resize
638
+ this.busy = false
639
+ this.resize(0, 0, 0, 0, true)
640
+ }
641
+
642
+ // if the video or track changed, we need to re-attach the offscreen canvas
643
+ _reAttachOffscreen () {
644
+ if (!this._offscreenRender || !this._ctx) return null
645
+ this._canvas.remove()
646
+ this._createCanvas()
647
+ this._canvasctrl = this._canvas.transferControlToOffscreen()
648
+ this._ctx = false
649
+ this.sendMessage('offscreenCanvas', null, [this._canvasctrl])
650
+ this.resize(0, 0, 0, 0, true)
651
+ }
652
+
653
+ _updateColorSpace () {
654
+ this._video.requestVideoFrameCallback(() => {
655
+ try {
656
+ // eslint-disable-next-line no-undef
657
+ const frame = new VideoFrame(this._video)
658
+ this._videoColorSpace = webYCbCrMap[frame.colorSpace.matrix]
659
+ frame.close()
660
+ this.sendMessage('getColorSpace')
661
+ } catch (e) {
662
+ // sources can be tainted
663
+ console.warn(e)
664
+ }
665
+ })
666
+ }
667
+
668
+ /**
669
+ * Veryify the color spaces for subtitles and videos, then apply filters to correct the color of subtitles.
670
+ * @param {Object} options
671
+ * @param {String} options.subtitleColorSpace Subtitle color space. One of: BT601 BT709 SMPTE240M FCC
672
+ * @param {String=} options.videoColorSpace Video color space. One of: BT601 BT709
673
+ */
674
+ _verifyColorSpace ({ subtitleColorSpace, videoColorSpace = this._videoColorSpace }) {
675
+ if (!subtitleColorSpace || !videoColorSpace) return
676
+ if (subtitleColorSpace === videoColorSpace) return
677
+ this._detachOffscreen()
678
+ this._ctx.filter = `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg'><filter id='f'><feColorMatrix type='matrix' values='${colorMatrixConversionMap[subtitleColorSpace][videoColorSpace]} 0 0 0 0 0 1 0'/></filter></svg>#f")`
679
+ }
680
+
681
+ _render ({ images, asyncRender, times, width, height, colorSpace }) {
682
+ this._unbusy()
683
+ if (this.debug) times.IPCTime = Date.now() - times.JSRenderTime
684
+ if (this._canvasctrl.width !== width || this._canvasctrl.height !== height) {
685
+ this._canvasctrl.width = width
686
+ this._canvasctrl.height = height
687
+ this._verifyColorSpace({ subtitleColorSpace: colorSpace })
688
+ }
689
+ this._ctx.clearRect(0, 0, this._canvasctrl.width, this._canvasctrl.height)
690
+ for (const image of images) {
691
+ if (image.image) {
692
+ if (asyncRender) {
693
+ this._ctx.drawImage(image.image, image.x, image.y)
694
+ image.image.close()
695
+ } else {
696
+ this._bufferCanvas.width = image.w
697
+ this._bufferCanvas.height = image.h
698
+ this._bufferCtx.putImageData(new ImageData(this._fixAlpha(new Uint8ClampedArray(image.image)), image.w, image.h), 0, 0)
699
+ this._ctx.drawImage(this._bufferCanvas, image.x, image.y)
700
+ }
701
+ }
702
+ }
703
+ if (this.debug) {
704
+ times.JSRenderTime = Date.now() - times.JSRenderTime - times.IPCTime
705
+ let total = 0
706
+ const count = times.bitmaps || images.length
707
+ delete times.bitmaps
708
+ for (const key in times) total += times[key]
709
+ console.log('Bitmaps: ' + count + ' Total: ' + (total | 0) + 'ms', times)
710
+ }
711
+ }
712
+
713
+ _fixAlpha (uint8) {
714
+ if (JASSUB._hasAlphaBug) {
715
+ for (let j = 3; j < uint8.length; j += 4) {
716
+ uint8[j] = uint8[j] > 1 ? uint8[j] : 1
717
+ }
718
+ }
719
+ return uint8
720
+ }
721
+
722
+ _ready () {
723
+ this._init()
724
+ this.dispatchEvent(new CustomEvent('ready'))
725
+ }
726
+
727
+ /**
728
+ * Send data and execute function in the worker.
729
+ * @param {String} target Target function.
730
+ * @param {Object} [data] Data for function.
731
+ * @param {Transferable[]} [transferable] Array of transferables.
732
+ */
733
+ async sendMessage (target, data = {}, transferable) {
734
+ await this._loaded
735
+ if (transferable) {
736
+ this._worker.postMessage({
737
+ target,
738
+ transferable,
739
+ ...data
740
+ }, [...transferable])
741
+ } else {
742
+ this._worker.postMessage({
743
+ target,
744
+ ...data
745
+ })
746
+ }
747
+ }
748
+
749
+ _fetchFromWorker (workerOptions, callback) {
750
+ try {
751
+ const target = workerOptions.target
752
+
753
+ const timeout = setTimeout(() => {
754
+ reject(new Error('Error: Timeout while try to fetch ' + target))
755
+ }, 5000)
756
+
757
+ const resolve = ({ data }) => {
758
+ if (data.target === target) {
759
+ callback(null, data)
760
+ this._worker.removeEventListener('message', resolve)
761
+ this._worker.removeEventListener('error', reject)
762
+ clearTimeout(timeout)
763
+ }
764
+ }
765
+
766
+ const reject = event => {
767
+ callback(event)
768
+ this._worker.removeEventListener('message', resolve)
769
+ this._worker.removeEventListener('error', reject)
770
+ clearTimeout(timeout)
771
+ }
772
+
773
+ this._worker.addEventListener('message', resolve)
774
+ this._worker.addEventListener('error', reject)
775
+
776
+ this._worker.postMessage(workerOptions)
777
+ } catch (error) {
778
+ this._error(error)
779
+ }
780
+ }
781
+
782
+ _console ({ content, command }) {
783
+ console[command].apply(console, JSON.parse(content))
784
+ }
785
+
786
+ _onmessage ({ data }) {
787
+ if (this['_' + data.target]) this['_' + data.target](data)
788
+ }
789
+
790
+ _error (err) {
791
+ const error = err instanceof Error
792
+ ? err // pass
793
+ : err instanceof ErrorEvent
794
+ ? err.error // ErrorEvent has error property which is an Error object
795
+ : new Error(err) // construct Error
796
+
797
+ const event = err instanceof Event
798
+ ? new ErrorEvent(err.type, err) // clone event
799
+ : new ErrorEvent('error', { error }) // construct Event
800
+
801
+ this.dispatchEvent(event)
802
+
803
+ console.error(error)
804
+
805
+ return error
806
+ }
807
+
808
+ _removeListeners () {
809
+ if (this._video) {
810
+ if (this._ro) this._ro.unobserve(this._video)
811
+ if (this._ctx) this._ctx.filter = 'none'
812
+ this._video.removeEventListener('timeupdate', this._boundTimeUpdate)
813
+ this._video.removeEventListener('progress', this._boundTimeUpdate)
814
+ this._video.removeEventListener('waiting', this._boundTimeUpdate)
815
+ this._video.removeEventListener('seeking', this._boundTimeUpdate)
816
+ this._video.removeEventListener('playing', this._boundTimeUpdate)
817
+ this._video.removeEventListener('ratechange', this._boundSetRate)
818
+ this._video.removeEventListener('resize', this._boundResize)
819
+ this._video.removeEventListener('loadedmetadata', this._boundUpdateColorSpace)
820
+ }
821
+ }
822
+
823
+ /**
824
+ * Destroy the object, worker, listeners and all data.
825
+ * @param {String|Error} [err] Error to throw when destroying.
826
+ */
827
+ destroy (err) {
828
+ if (err) err = this._error(err)
829
+ if (this._video && this._canvasParent) this._video.parentNode?.removeChild(this._canvasParent)
830
+ this._destroyed = true
831
+ this._removeListeners()
832
+ this.sendMessage('destroy')
833
+ this._worker?.terminate()
834
+ return err
835
+ }
836
+ }