akarisub 0.2.0 → 0.2.1

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