akarisub 0.1.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.
@@ -0,0 +1,1159 @@
1
+ /**
2
+ * Main AkariSub class - TypeScript implementation.
3
+ * High-level ASS/SSA subtitle renderer for web browsers using libass.
4
+ */
5
+
6
+ import type {
7
+ AkariSubOptions,
8
+ ASSEvent,
9
+ ASSStyle,
10
+ PerformanceStats,
11
+ RenderImage,
12
+ RenderTimes,
13
+ VideoFrameCallbackMetadata,
14
+ SubtitleColorSpace,
15
+ WebYCbCrColorSpace
16
+ } from './types'
17
+ import {
18
+ webYCbCrMap,
19
+ colorMatrixConversionMap,
20
+ computeCanvasSize,
21
+ getVideoPosition,
22
+ fixAlpha,
23
+ getAlphaBug,
24
+ getBitmapBug
25
+ } from './utils'
26
+ import { WebGPURenderer, isWebGPUSupported } from './webgpu-renderer'
27
+ import { WebGL2Renderer, isWebGL2Supported } from './webgl2-renderer'
28
+
29
+ type AnyGPURenderer = WebGPURenderer | WebGL2Renderer
30
+
31
+ /**
32
+ * AkariSub - JavaScript ASS/SSA Subtitle Renderer
33
+ *
34
+ * Renders ASS/SSA subtitles on an HTML5 video element using libass compiled to WebAssembly.
35
+ *
36
+ * @example
37
+ * ```typescript
38
+ * const renderer = new AkariSub({
39
+ * video: document.querySelector('video'),
40
+ * subUrl: '/subtitles/example.ass',
41
+ * workerUrl: '/akarisub-worker.js'
42
+ * });
43
+ *
44
+ * // Later, cleanup
45
+ * renderer.destroy();
46
+ * ```
47
+ */
48
+ export default class AkariSub extends EventTarget {
49
+ private static readonly MAX_PENDING_DEMANDS = 3
50
+
51
+ // Feature detection cache (static)
52
+ private static _hasAlphaBug: boolean | null = null
53
+ private static _hasBitmapBug: boolean | null = null
54
+
55
+ // Instance properties
56
+ private _loaded: Promise<void>
57
+ private _init!: () => void
58
+ private _onDemandRender: boolean
59
+ private _offscreenRender: boolean
60
+ private _video?: HTMLVideoElement
61
+ private _videoWidth: number = 0
62
+ private _videoHeight: number = 0
63
+ private _videoColorSpace: WebYCbCrColorSpace | null = null
64
+ private _canvas!: HTMLCanvasElement
65
+ private _canvasParent?: HTMLDivElement
66
+ private _bufferCanvas: HTMLCanvasElement
67
+ private _bufferCtx: CanvasRenderingContext2D
68
+ private _canvasctrl!: HTMLCanvasElement | OffscreenCanvas
69
+ private _ctx: CanvasRenderingContext2D | false | null = null
70
+ private _lastRenderTime: number = 0
71
+ private _playstate: boolean = true
72
+ private _destroyed: boolean = false
73
+ private _workerReady: boolean = false
74
+ private _ro?: ResizeObserver
75
+ private _worker: Worker
76
+ private _pendingDemandTimes: Array<{ mediaTime: number; width: number; height: number }> = []
77
+
78
+ // Bound methods for event listeners
79
+ private _boundResize: () => void
80
+ private _boundTimeUpdate: (e: Event) => void
81
+ private _boundSetRate: () => void
82
+ private _boundUpdateColorSpace: () => void
83
+ private _boundHandleRVFC: (now: number, metadata: VideoFrameCallbackMetadata) => void
84
+
85
+ // GPU renderer (WebGPU or WebGL2 – whichever initialises first in the fallback chain)
86
+ private _gpuRenderer: AnyGPURenderer | null = null
87
+ private _rendererType: 'webgpu' | 'webgl2' | 'canvas2d' = 'canvas2d'
88
+ private _onCanvasFallback?: () => void
89
+
90
+ // Cached render data to reduce allocations
91
+ private _lastRenderWidth: number = 0
92
+ private _lastRenderHeight: number = 0
93
+
94
+ // Public properties
95
+ public timeOffset: number
96
+ public debug: boolean
97
+ public prescaleFactor: number
98
+ public prescaleHeightLimit: number
99
+ public maxRenderHeight: number
100
+ public busy: boolean = false
101
+ public renderAhead: number
102
+
103
+ constructor(options: AkariSubOptions) {
104
+ super()
105
+
106
+ if (!globalThis.Worker) {
107
+ throw this.destroy(new Error('Worker not supported'))
108
+ }
109
+ if (!options) {
110
+ throw this.destroy(new Error('No options provided'))
111
+ }
112
+
113
+ this._loaded = new Promise((resolve) => {
114
+ this._init = resolve
115
+ })
116
+
117
+ // Run feature tests
118
+ const test = AkariSub._test()
119
+
120
+ this._onDemandRender = 'requestVideoFrameCallback' in HTMLVideoElement.prototype && (options.onDemandRender ?? true)
121
+
122
+ this._onCanvasFallback = options.onCanvasFallback
123
+
124
+ const canUseGPURenderer = !options.canvas && (isWebGPUSupported() || isWebGL2Supported())
125
+
126
+ // Don't support offscreen rendering on custom canvases
127
+ this._offscreenRender =
128
+ 'transferControlToOffscreen' in HTMLCanvasElement.prototype &&
129
+ !options.canvas &&
130
+ !canUseGPURenderer &&
131
+ (options.offscreenRender ?? true)
132
+
133
+ this.timeOffset = options.timeOffset || 0
134
+ this._video = options.video
135
+ this._canvas = options.canvas!
136
+
137
+ if (this._video && !this._canvas) {
138
+ this._canvasParent = document.createElement('div')
139
+ this._canvasParent.className = 'AkariSub'
140
+ this._canvasParent.style.position = 'relative'
141
+ this._canvas = this._createCanvas()
142
+ this._video.insertAdjacentElement('afterend', this._canvasParent)
143
+ } else if (!this._canvas) {
144
+ throw this.destroy(new Error("Don't know where to render: you should give video or canvas in options."))
145
+ }
146
+
147
+ this._bufferCanvas = document.createElement('canvas')
148
+ const bufferCtx = this._bufferCanvas.getContext('2d')
149
+ if (!bufferCtx) throw this.destroy(new Error('Canvas rendering not supported'))
150
+ this._bufferCtx = bufferCtx
151
+
152
+ // Try GPU renderers first (WebGPU → WebGL2 → Canvas2D)
153
+ if (canUseGPURenderer) {
154
+ this._initGPURenderer()
155
+ } else if (!this._offscreenRender) {
156
+ this._ctx = this._canvas.getContext('2d')
157
+ }
158
+
159
+ this._canvasctrl = this._offscreenRender
160
+ ? (
161
+ this._canvas as HTMLCanvasElement & { transferControlToOffscreen(): OffscreenCanvas }
162
+ ).transferControlToOffscreen()
163
+ : this._canvas
164
+
165
+ this._lastRenderTime = 0
166
+ this.debug = !!options.debug
167
+ this.prescaleFactor = options.prescaleFactor || 1.0
168
+ this.prescaleHeightLimit = options.prescaleHeightLimit || 1080
169
+ this.maxRenderHeight = options.maxRenderHeight || 0
170
+ this.renderAhead = options.renderAhead ?? 0
171
+
172
+ // Bind methods
173
+ this._boundResize = this.resize.bind(this)
174
+ this._boundTimeUpdate = this._timeupdate.bind(this)
175
+ this._boundSetRate = () => this.setRate((this._video as HTMLVideoElement).playbackRate)
176
+ this._boundUpdateColorSpace = this._updateColorSpace.bind(this)
177
+ this._boundHandleRVFC = this._handleRVFC.bind(this)
178
+
179
+ if (this._video) {
180
+ this.setVideo(this._video)
181
+ }
182
+
183
+ if (this._onDemandRender) {
184
+ this.busy = false
185
+ this._pendingDemandTimes.length = 0
186
+ }
187
+
188
+ // Create worker
189
+ this._worker = new Worker(options.workerUrl || 'akarisub-worker.js')
190
+ this._worker.onmessage = (e) => this._onmessage(e)
191
+ this._worker.onerror = (e) => this._error(e)
192
+
193
+ // Initialize worker after feature tests complete
194
+ test.then(() => {
195
+ this._worker.postMessage({
196
+ target: 'init',
197
+ wasmUrl: options.wasmUrl ?? 'akarisub-worker.wasm',
198
+ asyncRender: typeof createImageBitmap !== 'undefined' && (options.asyncRender ?? true),
199
+ onDemandRender: this._onDemandRender,
200
+ initialTime: (this._video?.currentTime ?? 0) + this.timeOffset,
201
+ width: this._canvasctrl.width || 0,
202
+ height: this._canvasctrl.height || 0,
203
+ blendMode: options.blendMode ?? 'wasm',
204
+ subUrl: options.subUrl,
205
+ subContent: options.subContent || null,
206
+ fonts: options.fonts || [],
207
+ availableFonts: options.availableFonts || { 'liberation sans': './default.woff2' },
208
+ fallbackFonts: options.fallbackFonts || ['liberation sans'],
209
+ debug: this.debug,
210
+ targetFps: options.targetFps || 24,
211
+ dropAllAnimations: options.dropAllAnimations,
212
+ dropAllBlur: options.dropAllBlur,
213
+ clampPos: options.clampPos,
214
+ libassMemoryLimit: options.libassMemoryLimit ?? 128,
215
+ libassGlyphLimit: options.libassGlyphLimit ?? 2048,
216
+ useLocalFonts: typeof (globalThis as any).queryLocalFonts !== 'undefined' && (options.useLocalFonts ?? true),
217
+ hasBitmapBug: AkariSub._hasBitmapBug
218
+ })
219
+
220
+ if (this._offscreenRender) {
221
+ this.sendMessage('offscreenCanvas', {}, [this._canvasctrl as OffscreenCanvas])
222
+ }
223
+ })
224
+ }
225
+
226
+ // ==========================================================================
227
+ // Static Methods
228
+ // ==========================================================================
229
+
230
+ private static async _testImageBugs(): Promise<void> {
231
+ if (AkariSub._hasBitmapBug !== null) return
232
+
233
+ const canvas1 = document.createElement('canvas')
234
+ const ctx1 = canvas1.getContext('2d', { willReadFrequently: true })
235
+ if (!ctx1) throw new Error('Canvas rendering not supported')
236
+
237
+ // Test ImageData constructor
238
+ if (typeof ImageData.prototype.constructor === 'function') {
239
+ try {
240
+ new ImageData(new Uint8ClampedArray([0, 0, 0, 0]), 1, 1)
241
+ } catch {
242
+ console.log('Detected that ImageData is not constructable despite browser saying so')
243
+ }
244
+ }
245
+
246
+ const canvas2 = document.createElement('canvas')
247
+ const ctx2 = canvas2.getContext('2d', { willReadFrequently: true })
248
+ if (!ctx2) throw new Error('Canvas rendering not supported')
249
+
250
+ canvas1.width = canvas2.width = 1
251
+ canvas1.height = canvas2.height = 1
252
+ ctx1.clearRect(0, 0, 1, 1)
253
+ ctx2.clearRect(0, 0, 1, 1)
254
+
255
+ const prePut = ctx2.getImageData(0, 0, 1, 1).data
256
+ ctx1.putImageData(new ImageData(new Uint8ClampedArray([0, 255, 0, 0]), 1, 1), 0, 0)
257
+ ctx2.drawImage(canvas1, 0, 0)
258
+ const postPut = ctx2.getImageData(0, 0, 1, 1).data
259
+
260
+ AkariSub._hasAlphaBug = prePut[1] !== postPut[1]
261
+ if (AkariSub._hasAlphaBug) {
262
+ console.log('Detected a browser having issue with transparent pixels, applying workaround')
263
+ }
264
+
265
+ if (typeof createImageBitmap !== 'undefined') {
266
+ const subarray = new Uint8ClampedArray([255, 0, 255, 0, 255]).subarray(1, 5)
267
+ ctx2.drawImage(await createImageBitmap(new ImageData(subarray, 1)), 0, 0)
268
+ const { data } = ctx2.getImageData(0, 0, 1, 1)
269
+ AkariSub._hasBitmapBug = false
270
+
271
+ for (let i = 0; i < data.length; i++) {
272
+ if (Math.abs(subarray[i] - data[i]) > 15) {
273
+ AkariSub._hasBitmapBug = true
274
+ console.log('Detected a browser having issue with partial bitmaps, applying workaround')
275
+ break
276
+ }
277
+ }
278
+ } else {
279
+ AkariSub._hasBitmapBug = false
280
+ }
281
+
282
+ canvas1.remove()
283
+ canvas2.remove()
284
+ }
285
+
286
+ private static async _test(): Promise<void> {
287
+ await AkariSub._testImageBugs()
288
+ }
289
+
290
+ // ==========================================================================
291
+ // GPU Renderer Management (WebGPU → WebGL2 → Canvas2D fallback)
292
+ // ==========================================================================
293
+
294
+ /**
295
+ * Attempt to initialise the best available GPU renderer.
296
+ */
297
+ private async _initGPURenderer(): Promise<void> {
298
+ if (isWebGPUSupported()) {
299
+ try {
300
+ const renderer = new WebGPURenderer()
301
+ await renderer.init()
302
+ if (!this._canvas) return
303
+ await renderer.setCanvas(this._canvas, Math.max(1, this._canvas.width || 1), Math.max(1, this._canvas.height || 1))
304
+ this._gpuRenderer = renderer
305
+ this._rendererType = 'webgpu'
306
+ console.log('[AkariSub] Using WebGPU renderer')
307
+ return
308
+ } catch (error) {
309
+ console.warn('[AkariSub] WebGPU init failed, trying WebGL2:', error)
310
+ }
311
+ }
312
+
313
+ if (isWebGL2Supported()) {
314
+ try {
315
+ const renderer = new WebGL2Renderer()
316
+ await renderer.init()
317
+ if (!this._canvas) return
318
+ await renderer.setCanvas(this._canvas, Math.max(1, this._canvas.width || 1), Math.max(1, this._canvas.height || 1))
319
+ this._gpuRenderer = renderer
320
+ this._rendererType = 'webgl2'
321
+ console.log('[AkariSub] Using WebGL2 renderer')
322
+ return
323
+ } catch (error) {
324
+ console.warn('[AkariSub] WebGL2 init failed, falling back to Canvas2D:', error)
325
+ }
326
+ }
327
+
328
+ this._rendererType = 'canvas2d'
329
+ if (!this._offscreenRender && !this._ctx) {
330
+ this._ctx = this._canvas.getContext('2d')
331
+ }
332
+ this.sendMessage('setAsyncRender', { value: false })
333
+ this._onCanvasFallback?.()
334
+ }
335
+
336
+ /** Returns which renderer backend is currently active. */
337
+ get rendererType(): 'webgpu' | 'webgl2' | 'canvas2d' {
338
+ return this._rendererType
339
+ }
340
+
341
+ /** @deprecated Use rendererType === 'webgpu' */
342
+ get isUsingWebGPU(): boolean {
343
+ return this._rendererType === 'webgpu'
344
+ }
345
+
346
+ /** Returns true when a hardware-accelerated GPU renderer is active. */
347
+ get isUsingGPURenderer(): boolean {
348
+ return this._gpuRenderer !== null
349
+ }
350
+
351
+ // ==========================================================================
352
+ // Canvas Management
353
+ // ==========================================================================
354
+
355
+ private _createCanvas(): HTMLCanvasElement {
356
+ this._canvas = document.createElement('canvas')
357
+ this._canvas.style.display = 'block'
358
+ this._canvas.style.position = 'absolute'
359
+ this._canvas.style.pointerEvents = 'none'
360
+ this._canvasParent!.appendChild(this._canvas)
361
+ return this._canvas
362
+ }
363
+
364
+ /**
365
+ * Resize the canvas to given parameters. Auto-generated if values are omitted.
366
+ */
367
+ resize(
368
+ width: number = 0,
369
+ height: number = 0,
370
+ top: number = 0,
371
+ left: number = 0,
372
+ force: boolean = this._video?.paused ?? false
373
+ ): void {
374
+ if ((!width || !height) && this._video) {
375
+ const videoSize = getVideoPosition(this._video)
376
+ let renderSize: { width: number; height: number }
377
+
378
+ if (this._videoWidth) {
379
+ const widthRatio = this._video.videoWidth / this._videoWidth
380
+ const heightRatio = this._video.videoHeight / this._videoHeight
381
+ renderSize = computeCanvasSize(
382
+ (videoSize.width || 0) / widthRatio,
383
+ (videoSize.height || 0) / heightRatio,
384
+ this.prescaleFactor,
385
+ this.prescaleHeightLimit,
386
+ this.maxRenderHeight
387
+ )
388
+ } else {
389
+ renderSize = computeCanvasSize(
390
+ videoSize.width || 0,
391
+ videoSize.height || 0,
392
+ this.prescaleFactor,
393
+ this.prescaleHeightLimit,
394
+ this.maxRenderHeight
395
+ )
396
+ }
397
+
398
+ width = renderSize.width
399
+ height = renderSize.height
400
+
401
+ if (this._canvasParent) {
402
+ top = videoSize.y - (this._canvasParent.getBoundingClientRect().top - this._video.getBoundingClientRect().top)
403
+ left = videoSize.x
404
+ }
405
+
406
+ this._canvas.style.width = videoSize.width + 'px'
407
+ this._canvas.style.height = videoSize.height + 'px'
408
+ }
409
+
410
+ this._canvas.style.top = top + 'px'
411
+ this._canvas.style.left = left + 'px'
412
+
413
+ if (width > 0 && height > 0) {
414
+ this._canvasctrl.width = width
415
+ this._canvasctrl.height = height
416
+ }
417
+
418
+ // Update GPU renderer size if using a GPU renderer
419
+ if (this._gpuRenderer && width > 0 && height > 0) {
420
+ this._gpuRenderer.updateSize(width, height)
421
+ }
422
+
423
+ if (force && this.busy === false) {
424
+ this.busy = true
425
+ } else {
426
+ force = false
427
+ }
428
+
429
+ this.sendMessage('canvas', {
430
+ width,
431
+ height,
432
+ videoWidth: this._videoWidth || this._video?.videoWidth || 0,
433
+ videoHeight: this._videoHeight || this._video?.videoHeight || 0,
434
+ force
435
+ })
436
+ }
437
+
438
+ // ==========================================================================
439
+ // Video Management
440
+ // ==========================================================================
441
+
442
+ private _timeupdate(event: Event): void {
443
+ const eventmap: Record<string, boolean> = {
444
+ seeking: true,
445
+ waiting: true,
446
+ playing: false
447
+ }
448
+ const playing = eventmap[event.type]
449
+ if (playing != null) this._playstate = playing
450
+ this.setCurrentTime(this._video!.paused || this._playstate, this._video!.currentTime + this.timeOffset)
451
+ }
452
+
453
+ /**
454
+ * Change the video to use as target for event listeners.
455
+ */
456
+ setVideo(video: HTMLVideoElement): void {
457
+ if (video instanceof HTMLVideoElement) {
458
+ this._removeListeners()
459
+ this._video = video
460
+
461
+ if (this._onDemandRender) {
462
+ if (!this._destroyed && this._video === video) {
463
+ ;(video as any).requestVideoFrameCallback(this._boundHandleRVFC)
464
+ }
465
+ } else {
466
+ this._playstate = video.paused
467
+
468
+ video.addEventListener('timeupdate', this._boundTimeUpdate, false)
469
+ video.addEventListener('progress', this._boundTimeUpdate, false)
470
+ video.addEventListener('waiting', this._boundTimeUpdate, false)
471
+ video.addEventListener('seeking', this._boundTimeUpdate, false)
472
+ video.addEventListener('playing', this._boundTimeUpdate, false)
473
+ video.addEventListener('ratechange', this._boundSetRate, false)
474
+ video.addEventListener('resize', this._boundResize, false)
475
+ }
476
+
477
+ if ('VideoFrame' in window) {
478
+ video.addEventListener('loadedmetadata', this._boundUpdateColorSpace, false)
479
+ if (video.readyState > 2) this._updateColorSpace()
480
+ }
481
+
482
+ if (video.videoWidth > 0) this.resize()
483
+
484
+ if (typeof ResizeObserver !== 'undefined') {
485
+ if (!this._ro) this._ro = new ResizeObserver(() => this.resize())
486
+ this._ro.observe(video)
487
+ }
488
+ } else {
489
+ this._error(new Error('Video element invalid!'))
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Run a benchmark on the worker.
495
+ */
496
+ runBenchmark(): void {
497
+ this.sendMessage('runBenchmark')
498
+ }
499
+
500
+ // ==========================================================================
501
+ // Track Management
502
+ // ==========================================================================
503
+
504
+ /**
505
+ * Overwrites the current subtitle content by URL.
506
+ */
507
+ setTrackByUrl(url: string): void {
508
+ this.sendMessage('setTrackByUrl', { url })
509
+ this._reAttachOffscreen()
510
+ if (this._ctx) this._ctx.filter = 'none'
511
+ }
512
+
513
+ /**
514
+ * Overwrites the current subtitle content.
515
+ */
516
+ setTrack(content: string): void {
517
+ this.sendMessage('setTrack', { content })
518
+ this._reAttachOffscreen()
519
+ if (this._ctx) this._ctx.filter = 'none'
520
+ }
521
+
522
+ /**
523
+ * Free currently used subtitle track.
524
+ */
525
+ freeTrack(): void {
526
+ this.sendMessage('freeTrack')
527
+ }
528
+
529
+ // ==========================================================================
530
+ // Playback Control
531
+ // ==========================================================================
532
+
533
+ /**
534
+ * Sets the playback state of the media.
535
+ */
536
+ setIsPaused(isPaused: boolean): void {
537
+ this.sendMessage('video', { isPaused })
538
+ }
539
+
540
+ /**
541
+ * Sets the playback rate of the media.
542
+ */
543
+ setRate(rate: number): void {
544
+ this.sendMessage('video', { rate })
545
+ }
546
+
547
+ /**
548
+ * Sets the current time, playback state and rate of the subtitles.
549
+ */
550
+ setCurrentTime(isPaused?: boolean, currentTime?: number, rate?: number): void {
551
+ this.sendMessage('video', {
552
+ isPaused,
553
+ currentTime,
554
+ rate,
555
+ colorSpace: this._videoColorSpace
556
+ })
557
+ }
558
+
559
+ // ==========================================================================
560
+ // Event Management
561
+ // ==========================================================================
562
+
563
+ /**
564
+ * Create a new ASS event directly.
565
+ */
566
+ createEvent(event: Partial<ASSEvent>): void {
567
+ this.sendMessage('createEvent', { event })
568
+ }
569
+
570
+ /**
571
+ * Overwrite the data of the event with the specified index.
572
+ */
573
+ setEvent(event: Partial<ASSEvent>, index: number): void {
574
+ this.sendMessage('setEvent', { event, index })
575
+ }
576
+
577
+ /**
578
+ * Remove the event with the specified index.
579
+ */
580
+ removeEvent(index: number): void {
581
+ this.sendMessage('removeEvent', { index })
582
+ }
583
+
584
+ /**
585
+ * Get all ASS events.
586
+ */
587
+ async getEvents(): Promise<ASSEvent[]> {
588
+ const data = await this._fetchFromWorker<{ events: ASSEvent[] }>({ target: 'getEvents' })
589
+ return data.events ?? []
590
+ }
591
+
592
+ // ==========================================================================
593
+ // Style Management
594
+ // ==========================================================================
595
+
596
+ /**
597
+ * Set a style override.
598
+ */
599
+ styleOverride(style: Partial<ASSStyle>): void {
600
+ this.sendMessage('styleOverride', { style })
601
+ }
602
+
603
+ /**
604
+ * Disable style override.
605
+ */
606
+ disableStyleOverride(): void {
607
+ this.sendMessage('disableStyleOverride')
608
+ }
609
+
610
+ /**
611
+ * Create a new ASS style directly.
612
+ */
613
+ createStyle(style: Partial<ASSStyle>): void {
614
+ this.sendMessage('createStyle', { style })
615
+ }
616
+
617
+ /**
618
+ * Overwrite the data of the style with the specified index.
619
+ */
620
+ setStyle(style: Partial<ASSStyle>, index: number): void {
621
+ this.sendMessage('setStyle', { style, index })
622
+ }
623
+
624
+ /**
625
+ * Remove the style with the specified index.
626
+ */
627
+ removeStyle(index: number): void {
628
+ this.sendMessage('removeStyle', { index })
629
+ }
630
+
631
+ /**
632
+ * Get all ASS styles.
633
+ */
634
+ async getStyles(): Promise<ASSStyle[]> {
635
+ const data = await this._fetchFromWorker<{ styles: ASSStyle[] }>({ target: 'getStyles' })
636
+ return data.styles ?? []
637
+ }
638
+
639
+ // ==========================================================================
640
+ // Font Management
641
+ // ==========================================================================
642
+
643
+ /**
644
+ * Adds a font to the renderer.
645
+ */
646
+ addFont(font: string | Uint8Array): void {
647
+ this.sendMessage('addFont', { font })
648
+ }
649
+
650
+ /**
651
+ * Changes the font family of the default font.
652
+ */
653
+ setDefaultFont(font: string): void {
654
+ this.sendMessage('defaultFont', { font })
655
+ }
656
+
657
+ // ==========================================================================
658
+ // Performance Stats
659
+ // ==========================================================================
660
+
661
+ /**
662
+ * Get real-time performance statistics.
663
+ */
664
+ async getStats(): Promise<PerformanceStats> {
665
+ const data = await this._fetchFromWorker<{ stats: Partial<PerformanceStats> }>({ target: 'getStats' })
666
+ const stats = data.stats
667
+ return {
668
+ framesRendered: stats.framesRendered ?? 0,
669
+ framesDropped: stats.framesDropped ?? 0,
670
+ avgRenderTime: stats.avgRenderTime ?? 0,
671
+ maxRenderTime: stats.maxRenderTime ?? 0,
672
+ minRenderTime: stats.minRenderTime ?? 0,
673
+ lastRenderTime: stats.lastRenderTime ?? 0,
674
+ pendingRenders: stats.pendingRenders ?? 0,
675
+ totalEvents: stats.totalEvents ?? 0,
676
+ cacheHits: stats.cacheHits ?? 0,
677
+ cacheMisses: stats.cacheMisses ?? 0,
678
+ renderFps: stats.avgRenderTime && stats.avgRenderTime > 0 ? Math.round(1000 / stats.avgRenderTime) : 0,
679
+ usingWorker: true,
680
+ offscreenRender: this._offscreenRender,
681
+ onDemandRender: this._onDemandRender
682
+ }
683
+ }
684
+
685
+ /**
686
+ * Reset performance statistics counters.
687
+ */
688
+ async resetStats(): Promise<void> {
689
+ await this._fetchFromWorker({ target: 'resetStats' })
690
+ }
691
+
692
+ /**
693
+ * Get event count
694
+ */
695
+ async getEventCount(): Promise<number> {
696
+ const data = await this._fetchFromWorker<{ count: number }>({ target: 'getEventCount' })
697
+ return data.count
698
+ }
699
+
700
+ /**
701
+ * Get style count
702
+ */
703
+ async getStyleCount(): Promise<number> {
704
+ const data = await this._fetchFromWorker<{ count: number }>({ target: 'getStyleCount' })
705
+ return data.count
706
+ }
707
+
708
+ // ==========================================================================
709
+ // Private Methods
710
+ // ==========================================================================
711
+
712
+ private _sendLocalFont(name: string): void {
713
+ try {
714
+ ; (globalThis as any).queryLocalFonts().then((fontData: any[]) => {
715
+ const font = fontData?.find((obj: any) => obj.fullName.toLowerCase() === name)
716
+ if (font) {
717
+ font.blob().then((blob: Blob) => {
718
+ blob.arrayBuffer().then((buffer: ArrayBuffer) => {
719
+ this.addFont(new Uint8Array(buffer))
720
+ })
721
+ })
722
+ }
723
+ })
724
+ } catch (e) {
725
+ console.warn('Local fonts API:', e)
726
+ }
727
+ }
728
+
729
+ private _getLocalFont(data: { font: string }): void {
730
+ try {
731
+ if (navigator?.permissions?.query) {
732
+ ; (navigator.permissions.query as any)({ name: 'local-fonts' }).then((permission: any) => {
733
+ if (permission.state === 'granted') {
734
+ this._sendLocalFont(data.font)
735
+ }
736
+ })
737
+ } else {
738
+ this._sendLocalFont(data.font)
739
+ }
740
+ } catch (e) {
741
+ console.warn('Local fonts API:', e)
742
+ }
743
+ }
744
+
745
+ private _unbusy(): void {
746
+ if (this._pendingDemandTimes.length > 0) {
747
+ if (this._pendingDemandTimes.length > 1) {
748
+ const latestDemand = this._pendingDemandTimes[this._pendingDemandTimes.length - 1]
749
+ this._pendingDemandTimes.length = 0
750
+ this._pendingDemandTimes.push(latestDemand)
751
+ }
752
+
753
+ const nextDemand = this._pendingDemandTimes.shift()
754
+ if (nextDemand) {
755
+ this._demandRender(nextDemand)
756
+ return
757
+ }
758
+ }
759
+
760
+ this.busy = false
761
+ }
762
+
763
+ private _enqueueDemand(metadata: { mediaTime: number; width: number; height: number }): void {
764
+ const queue = this._pendingDemandTimes
765
+
766
+ if (queue.length > 0) {
767
+ const lastQueued = queue[queue.length - 1]
768
+ if (Math.abs(lastQueued.mediaTime - metadata.mediaTime) > 0.25) {
769
+ queue.length = 0
770
+ }
771
+ }
772
+
773
+ if (queue.length >= AkariSub.MAX_PENDING_DEMANDS) {
774
+ queue.shift()
775
+ }
776
+
777
+ queue.push(metadata)
778
+ }
779
+
780
+ private _handleRVFC(now: number, metadata: VideoFrameCallbackMetadata): void {
781
+ if (this._destroyed) return
782
+
783
+ // Get the video's playback rate to correctly scale time offsets
784
+ const playbackRate = this._video?.playbackRate ?? 1
785
+ const renderTime = metadata.mediaTime + this.renderAhead * playbackRate
786
+
787
+ const demandData = {
788
+ mediaTime: renderTime,
789
+ width: metadata.width,
790
+ height: metadata.height
791
+ }
792
+
793
+ if (!this._workerReady) {
794
+ this._enqueueDemand(demandData)
795
+ ; (this._video as any).requestVideoFrameCallback(this._boundHandleRVFC)
796
+ return
797
+ }
798
+
799
+ if (this.busy) {
800
+ this._enqueueDemand(demandData)
801
+ } else {
802
+ this.busy = true
803
+ this._demandRender(demandData)
804
+ }
805
+
806
+ ; (this._video as any).requestVideoFrameCallback(this._boundHandleRVFC)
807
+ }
808
+
809
+ private _demandRender(metadata: { mediaTime: number; width: number; height: number }): void {
810
+ if (metadata.width !== this._videoWidth || metadata.height !== this._videoHeight) {
811
+ this._videoWidth = metadata.width
812
+ this._videoHeight = metadata.height
813
+ this.resize()
814
+ }
815
+
816
+ this.sendMessage('demand', { time: metadata.mediaTime + this.timeOffset })
817
+ }
818
+
819
+ private _detachOffscreen(): void {
820
+ if (!this._offscreenRender || this._ctx) return
821
+
822
+ this._canvas.remove()
823
+ this._createCanvas()
824
+ this._canvasctrl = this._canvas
825
+ this._ctx = this._canvasctrl.getContext('2d')
826
+ this.sendMessage('detachOffscreen')
827
+ this.busy = false
828
+ this._pendingDemandTimes.length = 0
829
+ this.resize(0, 0, 0, 0, true)
830
+ }
831
+
832
+ private _reAttachOffscreen(): void {
833
+ if (!this._offscreenRender || !this._ctx) return
834
+
835
+ this._canvas.remove()
836
+ this._createCanvas()
837
+ this._canvasctrl = (this._canvas as any).transferControlToOffscreen()
838
+ this._ctx = false
839
+ this.sendMessage('offscreenCanvas', {}, [this._canvasctrl as OffscreenCanvas])
840
+ this.resize(0, 0, 0, 0, true)
841
+ }
842
+
843
+ private _updateColorSpace(): void {
844
+ ; (this._video as any).requestVideoFrameCallback(() => {
845
+ try {
846
+ const frame = new (globalThis as any).VideoFrame(this._video)
847
+ this._videoColorSpace = webYCbCrMap[frame.colorSpace.matrix] ?? null
848
+ frame.close()
849
+ this.sendMessage('getColorSpace')
850
+ } catch (e) {
851
+ console.warn(e)
852
+ }
853
+ })
854
+ }
855
+
856
+ private _verifyColorSpace(data: {
857
+ subtitleColorSpace: SubtitleColorSpace
858
+ videoColorSpace?: WebYCbCrColorSpace | null
859
+ }): void {
860
+ const { subtitleColorSpace, videoColorSpace = this._videoColorSpace } = data
861
+
862
+ if (!subtitleColorSpace || !videoColorSpace) return
863
+ if (subtitleColorSpace === videoColorSpace) return
864
+
865
+ this._detachOffscreen()
866
+
867
+ const matrix = colorMatrixConversionMap[subtitleColorSpace]?.[videoColorSpace]
868
+ if (matrix && this._ctx) {
869
+ this._ctx.filter = `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg'><filter id='f'><feColorMatrix type='matrix' values='${matrix} 0 0 0 0 0 1 0'/></filter></svg>#f")`
870
+ }
871
+ }
872
+
873
+ private _render(data: {
874
+ images: RenderImage[]
875
+ asyncRender: boolean
876
+ times: RenderTimes
877
+ width: number
878
+ height: number
879
+ colorSpace: SubtitleColorSpace
880
+ }): void {
881
+ this._unbusy()
882
+
883
+ const dataWidth = data.width
884
+ const dataHeight = data.height
885
+
886
+ if (this.debug) {
887
+ data.times.IPCTime = Date.now() - (data.times.JSRenderTime || 0)
888
+ }
889
+
890
+ // Check if canvas size changed
891
+ const sizeChanged = this._canvasctrl.width !== dataWidth || this._canvasctrl.height !== dataHeight
892
+ if (sizeChanged) {
893
+ this._canvasctrl.width = dataWidth
894
+ this._canvasctrl.height = dataHeight
895
+ this._lastRenderWidth = dataWidth
896
+ this._lastRenderHeight = dataHeight
897
+
898
+ // Update GPU renderer size if canvas size changed
899
+ if (this._gpuRenderer) {
900
+ this._gpuRenderer.updateSize(dataWidth, dataHeight)
901
+ }
902
+
903
+ this._verifyColorSpace({ subtitleColorSpace: data.colorSpace })
904
+ }
905
+
906
+ // Use GPU renderer (WebGPU or WebGL2) if available
907
+ if (this._gpuRenderer) {
908
+ this._renderGPU(data)
909
+ return
910
+ }
911
+
912
+ if (!this._ctx) return
913
+
914
+ const ctx = this._ctx
915
+ const images = data.images
916
+ const imageCount = images.length
917
+
918
+ ctx.clearRect(0, 0, dataWidth, dataHeight)
919
+
920
+ if (data.asyncRender) {
921
+ for (let i = 0; i < imageCount; i++) {
922
+ const image = images[i]
923
+ if (image.image) {
924
+ ctx.drawImage(image.image as ImageBitmap, image.x, image.y)
925
+ ;(image.image as ImageBitmap).close()
926
+ }
927
+ }
928
+ } else {
929
+ const hasAlphaBug = AkariSub._hasAlphaBug ?? false
930
+
931
+ for (let i = 0; i < imageCount; i++) {
932
+ const image = images[i]
933
+ if (image.image) {
934
+ const imgW = image.w
935
+ const imgH = image.h
936
+
937
+ const rawData = new Uint8ClampedArray(image.image as ArrayBuffer)
938
+ const fixedData = fixAlpha(rawData, hasAlphaBug)
939
+ ctx.putImageData(new ImageData(fixedData as Uint8ClampedArray<ArrayBuffer>, imgW, imgH), image.x, image.y)
940
+ }
941
+ }
942
+ }
943
+
944
+ if (this.debug) {
945
+ data.times.JSRenderTime = Date.now() - (data.times.JSRenderTime || 0) - (data.times.IPCTime || 0)
946
+ let total = 0
947
+ const count = data.times.bitmaps || imageCount
948
+ delete data.times.bitmaps
949
+
950
+ for (const key in data.times) {
951
+ total += (data.times as any)[key] || 0
952
+ }
953
+
954
+ console.log('Bitmaps: ' + count + ' Total: ' + (total | 0) + 'ms', data.times)
955
+ }
956
+ }
957
+
958
+ private _renderGPU(data: { images: RenderImage[]; asyncRender: boolean; times: RenderTimes }): void {
959
+ const renderer = this._gpuRenderer
960
+ if (!renderer) return
961
+
962
+ if (data.images.length === 0) {
963
+ renderer.clear()
964
+ return
965
+ }
966
+
967
+ if (data.asyncRender) {
968
+ // For async render mode with ImageBitmaps
969
+ const bitmapImages = data.images
970
+ .filter((img) => img.image instanceof ImageBitmap)
971
+ .map((img) => ({
972
+ image: img.image as ImageBitmap,
973
+ x: img.x,
974
+ y: img.y
975
+ }))
976
+
977
+ renderer.renderBitmaps(bitmapImages, this._canvasctrl.width, this._canvasctrl.height)
978
+
979
+ // Close ImageBitmaps after rendering
980
+ for (const img of data.images) {
981
+ if (img.image instanceof ImageBitmap) {
982
+ img.image.close()
983
+ }
984
+ }
985
+ } else {
986
+ // For non-async render mode with ArrayBuffer data
987
+ renderer.render(data.images, this._canvasctrl.width, this._canvasctrl.height)
988
+ }
989
+
990
+ if (this.debug) {
991
+ data.times.JSRenderTime = Date.now() - (data.times.JSRenderTime || 0) - (data.times.IPCTime || 0)
992
+ let total = 0
993
+ const count = (data.times as any).bitmaps || data.images.length
994
+ delete (data.times as any).bitmaps
995
+
996
+ for (const key in data.times) {
997
+ total += (data.times as any)[key] || 0
998
+ }
999
+
1000
+ console.log(`[${this._rendererType.toUpperCase()}] Bitmaps: ` + count + ' Total: ' + (total | 0) + 'ms', data.times)
1001
+ }
1002
+ }
1003
+
1004
+ private _ready(): void {
1005
+ this._workerReady = true
1006
+ this._init()
1007
+
1008
+ if (this._onDemandRender && this._video) {
1009
+ this.setCurrentTime(this._video.paused, this._video.currentTime + this.timeOffset, this._video.playbackRate)
1010
+
1011
+ const pending = this._pendingDemandTimes.length > 0
1012
+ ? this._pendingDemandTimes[this._pendingDemandTimes.length - 1]
1013
+ : {
1014
+ mediaTime: this._video.currentTime + this.renderAhead * (this._video.playbackRate || 1),
1015
+ width: this._video.videoWidth,
1016
+ height: this._video.videoHeight
1017
+ }
1018
+
1019
+ this._pendingDemandTimes.length = 0
1020
+ this.busy = true
1021
+ this._demandRender(pending)
1022
+ }
1023
+
1024
+ this.dispatchEvent(new CustomEvent('ready'))
1025
+ }
1026
+
1027
+ /**
1028
+ * Handler for partial_ready message from worker.
1029
+ * Emitted early for large subtitle files to allow playback to start
1030
+ * while font loading and track parsing continues.
1031
+ */
1032
+ private _partial_ready(): void {
1033
+ this.dispatchEvent(new CustomEvent('partial_ready'))
1034
+ }
1035
+
1036
+ /**
1037
+ * Send data and execute function in the worker.
1038
+ */
1039
+ async sendMessage(target: string, data: Record<string, any> = {}, transferable?: Transferable[]): Promise<void> {
1040
+ await this._loaded
1041
+
1042
+ if (transferable) {
1043
+ this._worker.postMessage({ target, transferable, ...data }, [...transferable])
1044
+ } else {
1045
+ this._worker.postMessage({ target, ...data })
1046
+ }
1047
+ }
1048
+
1049
+ private _fetchFromWorker<T = any>(workerOptions: { target: string }): Promise<T> {
1050
+ return new Promise((resolve, reject) => {
1051
+ try {
1052
+ const target = workerOptions.target
1053
+
1054
+ const timeout = setTimeout(() => {
1055
+ cleanup()
1056
+ reject(new Error('Error: Timeout while trying to fetch ' + target))
1057
+ }, 5000)
1058
+
1059
+ const handleMessage = (event: MessageEvent) => {
1060
+ if (event.data.target === target) {
1061
+ cleanup()
1062
+ resolve(event.data as T)
1063
+ }
1064
+ }
1065
+
1066
+ const handleError = (event: ErrorEvent | Error) => {
1067
+ cleanup()
1068
+ reject(event instanceof Error ? event : event.error || new Error('Worker error'))
1069
+ }
1070
+
1071
+ const cleanup = () => {
1072
+ this._worker.removeEventListener('message', handleMessage)
1073
+ this._worker.removeEventListener('error', handleError as any)
1074
+ clearTimeout(timeout)
1075
+ }
1076
+
1077
+ this._worker.addEventListener('message', handleMessage)
1078
+ this._worker.addEventListener('error', handleError as any)
1079
+
1080
+ this._worker.postMessage(workerOptions)
1081
+ } catch (error) {
1082
+ reject(error)
1083
+ }
1084
+ })
1085
+ }
1086
+
1087
+ private _console(data: { content: string; command: string }): void {
1088
+ ; (console as any)[data.command].apply(console, JSON.parse(data.content))
1089
+ }
1090
+
1091
+ private _onmessage(event: MessageEvent): void {
1092
+ const target = event.data.target
1093
+ if (target === 'error') {
1094
+ this._error(event.data.error || 'Unknown worker error')
1095
+ return
1096
+ }
1097
+ const handler = (this as any)['_' + target]
1098
+ if (handler) {
1099
+ handler.call(this, event.data)
1100
+ }
1101
+ }
1102
+
1103
+ private _error(err: Error | ErrorEvent | string): Error {
1104
+ const error =
1105
+ err instanceof Error
1106
+ ? err
1107
+ : err instanceof ErrorEvent
1108
+ ? err.error || new Error(err.message)
1109
+ : new Error(String(err))
1110
+
1111
+ const event = err instanceof Event ? new ErrorEvent(err.type, err) : new ErrorEvent('error', { error })
1112
+
1113
+ this.dispatchEvent(event)
1114
+ console.error(error)
1115
+
1116
+ return error
1117
+ }
1118
+
1119
+ private _removeListeners(): void {
1120
+ if (this._video) {
1121
+ if (this._ro) this._ro.unobserve(this._video)
1122
+ if (this._ctx) this._ctx.filter = 'none'
1123
+
1124
+ this._video.removeEventListener('timeupdate', this._boundTimeUpdate)
1125
+ this._video.removeEventListener('progress', this._boundTimeUpdate)
1126
+ this._video.removeEventListener('waiting', this._boundTimeUpdate)
1127
+ this._video.removeEventListener('seeking', this._boundTimeUpdate)
1128
+ this._video.removeEventListener('playing', this._boundTimeUpdate)
1129
+ this._video.removeEventListener('ratechange', this._boundSetRate)
1130
+ this._video.removeEventListener('resize', this._boundResize)
1131
+ this._video.removeEventListener('loadedmetadata', this._boundUpdateColorSpace)
1132
+ }
1133
+ }
1134
+
1135
+ /**
1136
+ * Destroy the object, worker, listeners and all data.
1137
+ */
1138
+ destroy(err?: Error | string): Error | undefined {
1139
+ const error = err ? this._error(err) : undefined
1140
+
1141
+ if (this._video && this._canvasParent) {
1142
+ this._video.parentNode?.removeChild(this._canvasParent)
1143
+ }
1144
+
1145
+ // Clean up GPU renderer
1146
+ if (this._gpuRenderer) {
1147
+ this._gpuRenderer.destroy()
1148
+ this._gpuRenderer = null
1149
+ this._rendererType = 'canvas2d'
1150
+ }
1151
+
1152
+ this._destroyed = true
1153
+ this._removeListeners()
1154
+ this.sendMessage('destroy')
1155
+ this._worker?.terminate()
1156
+
1157
+ return error
1158
+ }
1159
+ }