akarisub 0.2.2 → 1.0.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.
Files changed (76) hide show
  1. package/README.md +198 -167
  2. package/THIRD_PARTY_NOTICES.md +55 -163
  3. package/dist/COPYRIGHT +56 -164
  4. package/dist/index.d.ts +12 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +11 -147
  7. package/dist/index.js.map +1 -0
  8. package/dist/ts/{ts/akarisub.d.ts → akarisub.d.ts} +80 -99
  9. package/dist/ts/akarisub.d.ts.map +1 -0
  10. package/dist/ts/akarisub.js +2086 -0
  11. package/dist/ts/akarisub.js.map +1 -0
  12. package/dist/ts/timing.d.ts +63 -0
  13. package/dist/ts/timing.d.ts.map +1 -0
  14. package/dist/ts/timing.js +219 -0
  15. package/dist/ts/timing.js.map +1 -0
  16. package/dist/ts/{ts/types.d.ts → types.d.ts} +103 -78
  17. package/dist/ts/types.d.ts.map +1 -0
  18. package/dist/ts/types.js +2 -0
  19. package/dist/ts/{ts/types.js.map → types.js.map} +1 -1
  20. package/dist/ts/{ts/utils.d.ts → utils.d.ts} +0 -31
  21. package/dist/ts/utils.d.ts.map +1 -0
  22. package/dist/ts/{ts/utils.js → utils.js} +0 -56
  23. package/dist/ts/utils.js.map +1 -0
  24. package/dist/ts/wasm.d.ts +12 -0
  25. package/dist/ts/wasm.d.ts.map +1 -0
  26. package/dist/ts/wasm.js +46 -0
  27. package/dist/ts/wasm.js.map +1 -0
  28. package/dist/ts/{ts/webgl2-renderer.d.ts → webgl2-renderer.d.ts} +0 -12
  29. package/dist/ts/webgl2-renderer.d.ts.map +1 -0
  30. package/dist/ts/{ts/webgl2-renderer.js → webgl2-renderer.js} +0 -27
  31. package/dist/ts/webgl2-renderer.js.map +1 -0
  32. package/dist/ts/{ts/webgpu-renderer.d.ts → webgpu-renderer.d.ts} +17 -16
  33. package/dist/ts/webgpu-renderer.d.ts.map +1 -0
  34. package/dist/ts/{ts/webgpu-renderer.js → webgpu-renderer.js} +148 -71
  35. package/dist/ts/webgpu-renderer.js.map +1 -0
  36. package/dist/ts/worker.d.ts +2 -0
  37. package/dist/ts/{ts/worker.d.ts.map → worker.d.ts.map} +1 -1
  38. package/dist/ts/{ts/worker.js → worker.js} +1064 -264
  39. package/dist/ts/worker.js.map +1 -0
  40. package/dist/wrapper.d.ts +8 -0
  41. package/dist/wrapper.d.ts.map +1 -0
  42. package/dist/{ts/index.js → wrapper.js} +4 -14
  43. package/dist/wrapper.js.map +1 -0
  44. package/package.json +24 -22
  45. package/pkg/akarisub.js +53 -0
  46. package/{dist/akarisub-worker.wasm → pkg/akarisub.wasm} +0 -0
  47. package/src/wrapper.ts +46 -0
  48. package/dist/akarisub-worker.js +0 -39
  49. package/dist/akarisub.umd.js +0 -159
  50. package/dist/ts/index.d.ts +0 -14
  51. package/dist/ts/index.d.ts.map +0 -1
  52. package/dist/ts/index.js.map +0 -1
  53. package/dist/ts/ts/akarisub.d.ts.map +0 -1
  54. package/dist/ts/ts/akarisub.js +0 -1073
  55. package/dist/ts/ts/akarisub.js.map +0 -1
  56. package/dist/ts/ts/types.d.ts.map +0 -1
  57. package/dist/ts/ts/types.js +0 -5
  58. package/dist/ts/ts/utils.d.ts.map +0 -1
  59. package/dist/ts/ts/utils.js.map +0 -1
  60. package/dist/ts/ts/webgl2-renderer.d.ts.map +0 -1
  61. package/dist/ts/ts/webgl2-renderer.js.map +0 -1
  62. package/dist/ts/ts/webgpu-renderer.d.ts.map +0 -1
  63. package/dist/ts/ts/webgpu-renderer.js.map +0 -1
  64. package/dist/ts/ts/worker.d.ts +0 -6
  65. package/dist/ts/ts/worker.js.map +0 -1
  66. package/dist/ts/wrapper.d.ts +0 -8
  67. package/dist/ts/wrapper.d.ts.map +0 -1
  68. package/dist/ts/wrapper.js +0 -9
  69. package/dist/ts/wrapper.js.map +0 -1
  70. package/src/ts/akarisub.ts +0 -1272
  71. package/src/ts/types.ts +0 -397
  72. package/src/ts/utils.ts +0 -512
  73. package/src/ts/webgl2-renderer.ts +0 -426
  74. package/src/ts/webgpu-renderer.ts +0 -691
  75. package/src/ts/worker.ts +0 -2052
  76. /package/{dist → assets}/default.woff2 +0 -0
@@ -0,0 +1,2086 @@
1
+ import { webYCbCrMap, colorMatrixConversionMap, computeCanvasSize, getVideoPosition, fixAlpha } from './utils';
2
+ import { WebGPURenderer, isWebGPUSupported } from './webgpu-renderer';
3
+ import { WebGL2Renderer, isWebGL2Supported } from './webgl2-renderer';
4
+ import { compensatedMediaTime, compositorScheduleLeadMs, estimateRefreshIntervalMs, isStalePresentation, normalizeFrameTimeline, predictFrameDisplayTimeMs, presentedFrameIndex, presentationLeadSeconds, resolvePresentationMediaTime, selectRenderMediaTime, subtitleTimeForFrame, updateTimingCompensation } from './timing';
5
+ import { getDefaultFontUrl, getWasmGlueUrl, getWasmUrl } from './wasm';
6
+ const DEFAULT_RENDER_AHEAD = 0;
7
+ const isLikelyWebKit = () => {
8
+ if (typeof navigator === 'undefined')
9
+ return false;
10
+ const userAgent = navigator.userAgent || '';
11
+ const vendor = navigator.vendor || '';
12
+ const isIOSWebKit = /\b(iPhone|iPad|iPod)\b/i.test(userAgent);
13
+ if (!/AppleWebKit/i.test(userAgent))
14
+ return false;
15
+ if (isIOSWebKit)
16
+ return true;
17
+ if (/\b(Chrome|Chromium|Edg|OPR|SamsungBrowser|Firefox)\b/i.test(userAgent)) {
18
+ return false;
19
+ }
20
+ return vendor.includes('Apple');
21
+ };
22
+ /**
23
+ * AkariSub - JavaScript ASS/SSA Subtitle Renderer
24
+ *
25
+ * Renders ASS/SSA subtitles on an HTML5 video element using libass compiled to WebAssembly.
26
+ *
27
+ * @example
28
+ * ```typescript
29
+ * const renderer = new AkariSub({
30
+ * video: document.querySelector('video'),
31
+ * subUrl: '/subtitles/example.ass'
32
+ * });
33
+ *
34
+ * // Later, cleanup
35
+ * renderer.destroy();
36
+ * ```
37
+ */
38
+ export default class AkariSub extends EventTarget {
39
+ static MAX_PENDING_DEMANDS = 3;
40
+ static MAX_FONT_BYTES = 32 * 1024 * 1024;
41
+ static _hasAlphaBug = null;
42
+ static _hasBitmapBug = null;
43
+ _loaded;
44
+ _init;
45
+ _destroyedSignal;
46
+ _resolveDestroyed;
47
+ _pendingWorkerRejectors = new Set();
48
+ _onDemandRender;
49
+ _offscreenRender;
50
+ _video;
51
+ _videoWidth = 0;
52
+ _videoHeight = 0;
53
+ _videoColorSpace = null;
54
+ _canvas;
55
+ _canvasParent;
56
+ _bufferCanvas;
57
+ _bufferCtx;
58
+ _canvasctrl;
59
+ _ctx = null;
60
+ _lastRenderTime = 0;
61
+ _playstate = true;
62
+ _destroyed = false;
63
+ _workerReady = false;
64
+ _frameBufferReadyEvent = null;
65
+ _ro;
66
+ _worker;
67
+ _pendingDemandTimes = [];
68
+ _demandTimings = new Map();
69
+ _adaptiveTiming;
70
+ _frameTimeline;
71
+ _preparedFrames = new Map();
72
+ _stagedCanvases = new Set();
73
+ _stageFrameIndices = new Map();
74
+ _stageDisplayTimes = new Map();
75
+ _committedStage = null;
76
+ _scheduledPreparedFrame = null;
77
+ _predictedDisplayTimes = new Map();
78
+ _displayClockOffsets = [];
79
+ _displayGridAnchorMs;
80
+ _lastClockMediaTime;
81
+ _lastClockPlaybackRate;
82
+ _lastPresentedFrameIndex;
83
+ _refreshSamples = [];
84
+ _refreshRafHandle = null;
85
+ _lastRefreshRafTime;
86
+ _prepareQueue = [];
87
+ _prepareRequests = new Map();
88
+ _nextPrepareId = 1;
89
+ _prepareForce = true;
90
+ _timingCompensationSeconds = 0;
91
+ _rvfcHandle = null;
92
+ _rvfcGeneration = 0;
93
+ _renderEpoch = 0;
94
+ _nextDemandId = 1;
95
+ _nextPresentationId = 1;
96
+ _latestPresentationId = 0;
97
+ _nextFontRequestId = 1;
98
+ _isLikelyWebKit;
99
+ _boundResize;
100
+ _boundTimeUpdate;
101
+ _boundSetRate;
102
+ _boundUpdateColorSpace;
103
+ _boundHandleRVFC;
104
+ _gpuRenderer = null;
105
+ _rendererType = 'canvas2d';
106
+ _onCanvasFallback;
107
+ _lastRenderWidth = 0;
108
+ _lastRenderHeight = 0;
109
+ _gpuBitmapImages = [];
110
+ timeOffset;
111
+ debug;
112
+ prescaleFactor;
113
+ prescaleHeightLimit;
114
+ maxRenderHeight;
115
+ busy = false;
116
+ renderAhead;
117
+ framePrefetch;
118
+ constructor(options) {
119
+ super();
120
+ this._destroyedSignal = new Promise((resolve) => {
121
+ this._resolveDestroyed = resolve;
122
+ });
123
+ if (!globalThis.Worker) {
124
+ throw this.destroy(new Error('Worker not supported'));
125
+ }
126
+ if (!options) {
127
+ throw this.destroy(new Error('No options provided'));
128
+ }
129
+ for (const [index, font] of (options.fonts ?? []).entries()) {
130
+ if (typeof font !== 'string' && font.byteLength > AkariSub.MAX_FONT_BYTES) {
131
+ throw new Error(`Font ${index + 1} exceeds the 32 MiB per-font limit`);
132
+ }
133
+ }
134
+ for (const [name, font] of Object.entries(options.availableFonts ?? {})) {
135
+ if (typeof font !== 'string' && font.byteLength > AkariSub.MAX_FONT_BYTES) {
136
+ throw new Error(`Font ${name} exceeds the 32 MiB per-font limit`);
137
+ }
138
+ }
139
+ this._loaded = new Promise((resolve) => {
140
+ this._init = resolve;
141
+ });
142
+ this._isLikelyWebKit = isLikelyWebKit();
143
+ const test = AkariSub._test();
144
+ this._onDemandRender = 'requestVideoFrameCallback' in HTMLVideoElement.prototype && (options.onDemandRender ?? true);
145
+ this._adaptiveTiming = options.adaptiveTiming ?? true;
146
+ this._frameTimeline = options.frameTimeline ? normalizeFrameTimeline(options.frameTimeline) : null;
147
+ this._onCanvasFallback = options.onCanvasFallback;
148
+ const isCustomCanvas = !!options.canvas;
149
+ const rawAssImageGpu = options.rawAssImageGpu ?? false;
150
+ const wantsOffscreenRender = options.offscreenRender ?? !isCustomCanvas;
151
+ const canTransferOffscreen = 'transferControlToOffscreen' in HTMLCanvasElement.prototype;
152
+ const canUseGPURenderer = !this._isLikelyWebKit && !isCustomCanvas && (isWebGPUSupported() || isWebGL2Supported());
153
+ const shouldUseAsyncRender = typeof createImageBitmap !== 'undefined' && (options.asyncRender ?? (!this._isLikelyWebKit && !canUseGPURenderer));
154
+ // Keep caller-owned canvases on the main thread by default. Worker-side raw
155
+ // ASS composition remains opt-in because it regresses several ordinary and
156
+ // animation-heavy custom-canvas workloads despite helping dense overlays.
157
+ this._offscreenRender = canTransferOffscreen && !canUseGPURenderer && wantsOffscreenRender;
158
+ this.timeOffset = options.timeOffset || 0;
159
+ this._video = options.video;
160
+ this._canvas = options.canvas;
161
+ if (this._video && !this._canvas) {
162
+ this._canvasParent = document.createElement('div');
163
+ this._canvasParent.className = 'AkariSub';
164
+ this._canvasParent.style.position = 'relative';
165
+ this._canvasParent.style.zIndex = '1';
166
+ this._canvasParent.style.isolation = 'isolate';
167
+ this._canvasParent.style.pointerEvents = 'none';
168
+ this._canvas = this._createCanvas();
169
+ this._video.insertAdjacentElement('afterend', this._canvasParent);
170
+ }
171
+ else if (!this._canvas) {
172
+ throw this.destroy(new Error("Don't know where to render: you should give video or canvas in options."));
173
+ }
174
+ this._startRefreshSampling();
175
+ this._bufferCanvas = document.createElement('canvas');
176
+ const bufferCtx = this._bufferCanvas.getContext('2d');
177
+ if (!bufferCtx)
178
+ throw this.destroy(new Error('Canvas rendering not supported'));
179
+ this._bufferCtx = bufferCtx;
180
+ if (canUseGPURenderer) {
181
+ this._initGPURenderer();
182
+ }
183
+ else if (!this._offscreenRender) {
184
+ this._ctx = this._canvas.getContext('2d', { alpha: true });
185
+ }
186
+ this._canvasctrl = this._offscreenRender
187
+ ? this._canvas.transferControlToOffscreen()
188
+ : this._canvas;
189
+ this._lastRenderTime = 0;
190
+ this.debug = !!options.debug;
191
+ this.prescaleFactor = options.prescaleFactor || 1.0;
192
+ this.prescaleHeightLimit = options.prescaleHeightLimit || 1080;
193
+ this.maxRenderHeight = options.maxRenderHeight || 0;
194
+ this.renderAhead = options.renderAhead ?? DEFAULT_RENDER_AHEAD;
195
+ this.framePrefetch = Math.max(0, Math.min(24, Math.floor(options.framePrefetch ?? 2)));
196
+ this._boundResize = this.resize.bind(this);
197
+ this._boundTimeUpdate = this._timeupdate.bind(this);
198
+ this._boundSetRate = () => this._syncVideoClock(new Event('ratechange'));
199
+ this._boundUpdateColorSpace = this._updateColorSpace.bind(this);
200
+ this._boundHandleRVFC = this._handleRVFC.bind(this);
201
+ if (this._video) {
202
+ this.setVideo(this._video);
203
+ }
204
+ if (this._onDemandRender) {
205
+ this.busy = false;
206
+ this._pendingDemandTimes.length = 0;
207
+ }
208
+ this._worker = options.workerUrl
209
+ ? new Worker(options.workerUrl, { type: 'module' })
210
+ : new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
211
+ this._worker.onmessage = (e) => this._onmessage(e);
212
+ this._worker.onerror = (e) => this._error(e);
213
+ test.then(() => {
214
+ const initialTime = (this._video?.currentTime ?? 0) + this.timeOffset;
215
+ const initialPlaybackRate = this._videoPlaybackRateForWorker();
216
+ const initMessage = {
217
+ target: 'init',
218
+ wasmUrl: options.wasmUrl ?? getWasmUrl(),
219
+ glueUrl: options.glueUrl ?? getWasmGlueUrl(),
220
+ asyncRender: shouldUseAsyncRender,
221
+ fullTrackWarmup: options.fullTrackWarmup ?? false,
222
+ blockingFullTrackWarmup: options.blockingFullTrackWarmup ?? false,
223
+ fullTrackWarmupStep: options.fullTrackWarmupStep ?? 1,
224
+ adaptiveBlendLayouts: options.adaptiveBlendLayouts ?? false,
225
+ rawAssImageGpu,
226
+ onDemandRender: this._onDemandRender,
227
+ initialTime,
228
+ initialIsPaused: this._isVideoPausedForWorker(),
229
+ initialPlaybackRate,
230
+ initialTimeSnapshotAtMs: Date.now(),
231
+ width: this._canvasctrl.width || 0,
232
+ height: this._canvasctrl.height || 0,
233
+ blendMode: options.blendMode ?? 'wasm',
234
+ subUrl: options.subUrl,
235
+ subContent: options.subContent || null,
236
+ encryptedSubContent: options.encryptedSubContent || null,
237
+ fonts: options.fonts || [],
238
+ availableFonts: options.availableFonts || { 'liberation sans': getDefaultFontUrl() },
239
+ fallbackFonts: options.fallbackFonts || ['liberation sans'],
240
+ debug: this.debug,
241
+ targetFps: options.targetFps || 24,
242
+ renderAhead: this.renderAhead,
243
+ adaptiveTiming: this._adaptiveTiming,
244
+ frameTimelineMode: this._frameTimeline != null && this.framePrefetch > 0,
245
+ dropAllAnimations: options.dropAllAnimations,
246
+ dropAllBlur: options.dropAllBlur,
247
+ clampPos: options.clampPos,
248
+ libassMemoryLimit: options.libassMemoryLimit ?? 128,
249
+ libassGlyphLimit: options.libassGlyphLimit ?? 2048,
250
+ useLocalFonts: typeof globalThis.queryLocalFonts !== 'undefined' && (options.useLocalFonts ?? true),
251
+ useFontconfigProvider: options.useFontconfigProvider ?? true,
252
+ hasBitmapBug: AkariSub._hasBitmapBug
253
+ };
254
+ this._worker.postMessage(initMessage, AkariSub._getSubtitleTransfers(options.subContent, options.encryptedSubContent));
255
+ if (this._offscreenRender) {
256
+ const offscreenCanvas = this._canvasctrl;
257
+ // Post this immediately after init, before the worker can emit ready and
258
+ // trigger an on-demand render. sendMessage() waits for ready and races
259
+ // the first render, leaving offscreenRender=true with no canvas bound.
260
+ this._worker.postMessage({
261
+ target: 'offscreenCanvas',
262
+ rawAssImageGpu,
263
+ transferable: [offscreenCanvas]
264
+ }, [offscreenCanvas]);
265
+ }
266
+ });
267
+ }
268
+ static async _testImageBugs() {
269
+ if (AkariSub._hasBitmapBug !== null)
270
+ return;
271
+ const canvas1 = document.createElement('canvas');
272
+ const ctx1 = canvas1.getContext('2d', { willReadFrequently: true });
273
+ if (!ctx1)
274
+ throw new Error('Canvas rendering not supported');
275
+ if (typeof ImageData.prototype.constructor === 'function') {
276
+ try {
277
+ new ImageData(new Uint8ClampedArray([0, 0, 0, 0]), 1, 1);
278
+ }
279
+ catch {
280
+ console.log('Detected that ImageData is not constructable despite browser saying so');
281
+ }
282
+ }
283
+ const canvas2 = document.createElement('canvas');
284
+ const ctx2 = canvas2.getContext('2d', { willReadFrequently: true });
285
+ if (!ctx2)
286
+ throw new Error('Canvas rendering not supported');
287
+ canvas1.width = canvas2.width = 1;
288
+ canvas1.height = canvas2.height = 1;
289
+ ctx1.clearRect(0, 0, 1, 1);
290
+ ctx2.clearRect(0, 0, 1, 1);
291
+ const prePut = ctx2.getImageData(0, 0, 1, 1).data;
292
+ ctx1.putImageData(new ImageData(new Uint8ClampedArray([0, 255, 0, 0]), 1, 1), 0, 0);
293
+ ctx2.drawImage(canvas1, 0, 0);
294
+ const postPut = ctx2.getImageData(0, 0, 1, 1).data;
295
+ AkariSub._hasAlphaBug = prePut[1] !== postPut[1];
296
+ if (AkariSub._hasAlphaBug) {
297
+ console.log('Detected a browser having issue with transparent pixels, applying workaround');
298
+ }
299
+ if (typeof createImageBitmap !== 'undefined') {
300
+ const subarray = new Uint8ClampedArray([255, 0, 255, 0, 255]).subarray(1, 5);
301
+ ctx2.drawImage(await createImageBitmap(new ImageData(subarray, 1)), 0, 0);
302
+ const { data } = ctx2.getImageData(0, 0, 1, 1);
303
+ AkariSub._hasBitmapBug = false;
304
+ for (let i = 0; i < data.length; i++) {
305
+ if (Math.abs(subarray[i] - data[i]) > 15) {
306
+ AkariSub._hasBitmapBug = true;
307
+ console.log('Detected a browser having issue with partial bitmaps, applying workaround');
308
+ break;
309
+ }
310
+ }
311
+ }
312
+ else {
313
+ AkariSub._hasBitmapBug = false;
314
+ }
315
+ canvas1.remove();
316
+ canvas2.remove();
317
+ }
318
+ static async _test() {
319
+ await AkariSub._testImageBugs();
320
+ }
321
+ static _getSubtitleTransfers(subContent, encryptedSubContent) {
322
+ const transfers = [];
323
+ if (subContent instanceof ArrayBuffer) {
324
+ transfers.push(subContent);
325
+ }
326
+ else if (subContent instanceof Uint8Array) {
327
+ transfers.push(subContent.buffer);
328
+ }
329
+ if (encryptedSubContent?.encrypted) {
330
+ transfers.push(encryptedSubContent.encrypted);
331
+ }
332
+ for (const chunk of encryptedSubContent?.encryptedChunks || []) {
333
+ transfers.push(chunk);
334
+ }
335
+ return transfers;
336
+ }
337
+ async _initGPURenderer() {
338
+ if (isWebGPUSupported()) {
339
+ try {
340
+ const renderer = new WebGPURenderer();
341
+ await renderer.init();
342
+ if (!this._canvas || this._destroyed) {
343
+ renderer.destroy();
344
+ return;
345
+ }
346
+ await renderer.setCanvas(this._canvas, Math.max(1, this._canvas.width || 1), Math.max(1, this._canvas.height || 1));
347
+ if (this._destroyed) {
348
+ renderer.destroy();
349
+ return;
350
+ }
351
+ this._gpuRenderer = renderer;
352
+ this._rendererType = 'webgpu';
353
+ console.log('[AkariSub] Using WebGPU renderer');
354
+ return;
355
+ }
356
+ catch (error) {
357
+ console.warn('[AkariSub] WebGPU init failed, trying WebGL2:', error);
358
+ }
359
+ }
360
+ if (isWebGL2Supported()) {
361
+ try {
362
+ const renderer = new WebGL2Renderer();
363
+ await renderer.init();
364
+ if (!this._canvas || this._destroyed) {
365
+ renderer.destroy();
366
+ return;
367
+ }
368
+ await renderer.setCanvas(this._canvas, Math.max(1, this._canvas.width || 1), Math.max(1, this._canvas.height || 1));
369
+ if (this._destroyed) {
370
+ renderer.destroy();
371
+ return;
372
+ }
373
+ this._gpuRenderer = renderer;
374
+ this._rendererType = 'webgl2';
375
+ console.log('[AkariSub] Using WebGL2 renderer');
376
+ return;
377
+ }
378
+ catch (error) {
379
+ console.warn('[AkariSub] WebGL2 init failed, falling back to Canvas2D:', error);
380
+ }
381
+ }
382
+ this._rendererType = 'canvas2d';
383
+ if (!this._offscreenRender && !this._ctx) {
384
+ this._ctx = this._canvas.getContext('2d', { alpha: true });
385
+ }
386
+ this.sendMessage('setAsyncRender', { value: false });
387
+ this._onCanvasFallback?.();
388
+ }
389
+ get rendererType() {
390
+ return this._rendererType;
391
+ }
392
+ /** @deprecated Use rendererType === 'webgpu' */
393
+ get isUsingWebGPU() {
394
+ return this._rendererType === 'webgpu';
395
+ }
396
+ get isUsingGPURenderer() {
397
+ return this._gpuRenderer !== null;
398
+ }
399
+ _createCanvas() {
400
+ this._canvas = document.createElement('canvas');
401
+ this._canvas.style.display = 'block';
402
+ this._canvas.style.position = 'absolute';
403
+ this._canvas.style.pointerEvents = 'none';
404
+ this._canvas.style.zIndex = '0';
405
+ this._canvasParent.appendChild(this._canvas);
406
+ return this._canvas;
407
+ }
408
+ resize(width = 0, height = 0, top = 0, left = 0, force = this._video?.paused ?? false) {
409
+ if ((!width || !height) && this._video) {
410
+ const videoSize = getVideoPosition(this._video);
411
+ let renderSize;
412
+ if (this._videoWidth) {
413
+ const widthRatio = this._video.videoWidth / this._videoWidth;
414
+ const heightRatio = this._video.videoHeight / this._videoHeight;
415
+ renderSize = computeCanvasSize((videoSize.width || 0) / widthRatio, (videoSize.height || 0) / heightRatio, this.prescaleFactor, this.prescaleHeightLimit, this.maxRenderHeight);
416
+ }
417
+ else {
418
+ renderSize = computeCanvasSize(videoSize.width || 0, videoSize.height || 0, this.prescaleFactor, this.prescaleHeightLimit, this.maxRenderHeight);
419
+ }
420
+ width = renderSize.width;
421
+ height = renderSize.height;
422
+ if (this._canvasParent) {
423
+ top = videoSize.y - (this._canvasParent.getBoundingClientRect().top - this._video.getBoundingClientRect().top);
424
+ left = videoSize.x;
425
+ }
426
+ this._canvas.style.width = videoSize.width + 'px';
427
+ this._canvas.style.height = videoSize.height + 'px';
428
+ }
429
+ this._canvas.style.top = top + 'px';
430
+ this._canvas.style.left = left + 'px';
431
+ for (const stage of this._stagedCanvases)
432
+ this._syncStagedCanvasLayout(stage);
433
+ if (width > 0 && height > 0 && (this._canvasctrl.width !== width || this._canvasctrl.height !== height)) {
434
+ this._bumpRenderEpoch();
435
+ this._canvasctrl.width = width;
436
+ this._canvasctrl.height = height;
437
+ }
438
+ if (this._gpuRenderer && width > 0 && height > 0) {
439
+ this._gpuRenderer.updateSize(width, height);
440
+ }
441
+ if (force && this.busy === false) {
442
+ this.busy = true;
443
+ }
444
+ else {
445
+ force = false;
446
+ }
447
+ const storageWidth = this._videoWidth || this._video?.videoWidth || width || this._canvasctrl.width || 0;
448
+ const storageHeight = this._videoHeight || this._video?.videoHeight || height || this._canvasctrl.height || 0;
449
+ this.sendMessage('canvas', {
450
+ width,
451
+ height,
452
+ videoWidth: storageWidth,
453
+ videoHeight: storageHeight,
454
+ force
455
+ });
456
+ }
457
+ _timeupdate(event) {
458
+ this._syncVideoClock(event);
459
+ }
460
+ setVideo(video) {
461
+ if (video instanceof HTMLVideoElement) {
462
+ this._removeListeners();
463
+ this._video = video;
464
+ this._playstate = video.paused || video.ended;
465
+ if (this._onDemandRender) {
466
+ if (!this._destroyed && this._video === video) {
467
+ this._scheduleRVFC(video);
468
+ }
469
+ }
470
+ else {
471
+ video.addEventListener('resize', this._boundResize, false);
472
+ }
473
+ video.addEventListener('timeupdate', this._boundTimeUpdate, false);
474
+ video.addEventListener('progress', this._boundTimeUpdate, false);
475
+ video.addEventListener('play', this._boundTimeUpdate, false);
476
+ video.addEventListener('playing', this._boundTimeUpdate, false);
477
+ video.addEventListener('pause', this._boundTimeUpdate, false);
478
+ video.addEventListener('ended', this._boundTimeUpdate, false);
479
+ video.addEventListener('waiting', this._boundTimeUpdate, false);
480
+ video.addEventListener('stalled', this._boundTimeUpdate, false);
481
+ video.addEventListener('seeking', this._boundTimeUpdate, false);
482
+ video.addEventListener('seeked', this._boundTimeUpdate, false);
483
+ video.addEventListener('ratechange', this._boundSetRate, false);
484
+ if ('VideoFrame' in window) {
485
+ video.addEventListener('loadedmetadata', this._boundUpdateColorSpace, false);
486
+ if (video.readyState > 2)
487
+ this._updateColorSpace();
488
+ }
489
+ if (video.videoWidth > 0)
490
+ this.resize();
491
+ this._syncVideoClock();
492
+ if (typeof ResizeObserver !== 'undefined') {
493
+ if (!this._ro)
494
+ this._ro = new ResizeObserver(() => this.resize());
495
+ this._ro.observe(video);
496
+ }
497
+ }
498
+ else {
499
+ this._error(new Error('Video element invalid!'));
500
+ }
501
+ }
502
+ setTrackByUrl(url) {
503
+ this._bumpRenderEpoch();
504
+ this.sendMessage('setTrackByUrl', { url });
505
+ this._reAttachOffscreen();
506
+ if (this._ctx)
507
+ this._ctx.filter = 'none';
508
+ }
509
+ setTrack(content) {
510
+ this._bumpRenderEpoch();
511
+ this.sendMessage('setTrack', { content }, AkariSub._getSubtitleTransfers(content));
512
+ this._reAttachOffscreen();
513
+ if (this._ctx)
514
+ this._ctx.filter = 'none';
515
+ }
516
+ /**
517
+ * Overwrites the current subtitle content with encrypted v2 payloads.
518
+ * Decryption happens inside the AkariSub worker so plaintext ASS text is not
519
+ * materialized in the main thread.
520
+ */
521
+ setEncryptedTrack(content) {
522
+ this._bumpRenderEpoch();
523
+ this.sendMessage('setEncryptedTrack', { content }, AkariSub._getSubtitleTransfers(undefined, content));
524
+ this._reAttachOffscreen();
525
+ if (this._ctx)
526
+ this._ctx.filter = 'none';
527
+ }
528
+ freeTrack() {
529
+ this._sendMutatingMessage('freeTrack');
530
+ }
531
+ setIsPaused(isPaused) {
532
+ if (this._video) {
533
+ this._playstate = isPaused;
534
+ this._syncVideoClock();
535
+ return;
536
+ }
537
+ this.sendMessage('video', { isPaused });
538
+ }
539
+ setRate(rate) {
540
+ if (this._video) {
541
+ this.setCurrentTime(this._isVideoPausedForWorker(), this._currentVideoTimeWithOffset(), rate);
542
+ return;
543
+ }
544
+ this.sendMessage('video', { rate });
545
+ }
546
+ setCurrentTime(isPaused, currentTime, rate) {
547
+ this.sendMessage('video', {
548
+ isPaused,
549
+ currentTime,
550
+ rate,
551
+ renderAhead: this.renderAhead,
552
+ colorSpace: this._videoColorSpace
553
+ });
554
+ }
555
+ createEvent(event) {
556
+ this._sendMutatingMessage('createEvent', { event });
557
+ }
558
+ setEvent(event, index) {
559
+ this._sendMutatingMessage('setEvent', { event, index });
560
+ }
561
+ removeEvent(index) {
562
+ this._sendMutatingMessage('removeEvent', { index });
563
+ }
564
+ async getEvents() {
565
+ const data = await this._fetchFromWorker({ target: 'getEvents' });
566
+ return data.events ?? [];
567
+ }
568
+ styleOverride(style) {
569
+ this._sendMutatingMessage('styleOverride', { style });
570
+ }
571
+ disableStyleOverride() {
572
+ this._sendMutatingMessage('disableStyleOverride');
573
+ }
574
+ createStyle(style) {
575
+ this._sendMutatingMessage('createStyle', { style });
576
+ }
577
+ setStyle(style, index) {
578
+ this._sendMutatingMessage('setStyle', { style, index });
579
+ }
580
+ removeStyle(index) {
581
+ this._sendMutatingMessage('removeStyle', { index });
582
+ }
583
+ async getStyles() {
584
+ const data = await this._fetchFromWorker({ target: 'getStyles' });
585
+ return data.styles ?? [];
586
+ }
587
+ async addFont(font) {
588
+ this._bumpRenderEpoch();
589
+ if (typeof font !== 'string' && font.byteLength > AkariSub.MAX_FONT_BYTES) {
590
+ throw new Error('Font files are limited to 32 MiB');
591
+ }
592
+ const ready = this._workerReady ||
593
+ (await Promise.race([this._loaded.then(() => true), this._destroyedSignal.then(() => false)]));
594
+ if (!ready || this._destroyed)
595
+ throw new Error('Renderer was destroyed before the font could be added');
596
+ const requestId = this._nextFontRequestId++;
597
+ const result = await this._fetchFromWorker({
598
+ target: 'addFont',
599
+ font,
600
+ requestId,
601
+ timeoutMs: null
602
+ });
603
+ if (!result.success)
604
+ throw new Error(result.error || 'The renderer rejected the font');
605
+ this._syncVideoClock();
606
+ }
607
+ setDefaultFont(font) {
608
+ this._sendMutatingMessage('defaultFont', { font });
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
+ timingCompensationMs: this._onDemandRender
621
+ ? Math.round(this._timingCompensationSeconds * 100_000) / 100
622
+ : stats.timingCompensationMs,
623
+ lastImageCount: stats.lastImageCount,
624
+ lastImagePixels: stats.lastImagePixels,
625
+ pendingRenders: stats.pendingRenders ?? 0,
626
+ totalEvents: stats.totalEvents ?? 0,
627
+ cacheHits: stats.cacheHits ?? 0,
628
+ cacheMisses: stats.cacheMisses ?? 0,
629
+ renderFps: stats.avgRenderTime && stats.avgRenderTime > 0 ? Math.round(1000 / stats.avgRenderTime) : 0,
630
+ usingWorker: stats.usingWorker ?? true,
631
+ rawAssImageGpu: stats.rawAssImageGpu,
632
+ workerRenderer: stats.workerRenderer,
633
+ offscreenRender: stats.offscreenRender ?? this._offscreenRender,
634
+ onDemandRender: stats.onDemandRender ?? this._onDemandRender
635
+ };
636
+ }
637
+ async resetStats() {
638
+ await this._fetchFromWorker({ target: 'resetStats' });
639
+ }
640
+ /**
641
+ * Set encoded video-frame timestamps used to snap live predictions to exact
642
+ * libass sampling times. Pass null to return to continuous-time prediction.
643
+ */
644
+ setFrameTimeline(frameTimes) {
645
+ this._frameTimeline = frameTimes ? normalizeFrameTimeline(frameTimes) : null;
646
+ if (this._frameTimeline)
647
+ this._startRefreshSampling();
648
+ this._bumpRenderEpoch();
649
+ void this.sendMessage('frameTimelineMode', {
650
+ enabled: this._frameTimeline != null && this.framePrefetch > 0
651
+ });
652
+ this._syncVideoClock();
653
+ this._primePreparedFrames(this._currentExactFrameMediaTime());
654
+ this._dispatchNextPreparation();
655
+ }
656
+ async getEventCount() {
657
+ const data = await this._fetchFromWorker({ target: 'getEventCount' });
658
+ return data.count;
659
+ }
660
+ async getStyleCount() {
661
+ const data = await this._fetchFromWorker({ target: 'getStyleCount' });
662
+ return data.count;
663
+ }
664
+ async _sendLocalFont(name) {
665
+ let success = false;
666
+ try {
667
+ const fontData = await globalThis.queryLocalFonts();
668
+ const font = fontData?.find((obj) => obj.fullName.toLowerCase() === name);
669
+ if (font) {
670
+ const blob = await font.blob();
671
+ const buffer = await blob.arrayBuffer();
672
+ await this.addFont(new Uint8Array(buffer));
673
+ success = true;
674
+ }
675
+ }
676
+ catch (error) {
677
+ console.warn('Local fonts API:', error);
678
+ }
679
+ finally {
680
+ if (!this._destroyed) {
681
+ void this.sendMessage('localFontResult', { font: name, success }).catch((error) => {
682
+ if (!this._destroyed)
683
+ console.warn('Local font result:', error);
684
+ });
685
+ }
686
+ }
687
+ }
688
+ _getLocalFont(data) {
689
+ try {
690
+ if (navigator?.permissions?.query) {
691
+ ;
692
+ navigator.permissions.query({ name: 'local-fonts' }).then((permission) => {
693
+ if (permission.state === 'granted') {
694
+ void this._sendLocalFont(data.font);
695
+ }
696
+ });
697
+ }
698
+ else {
699
+ void this._sendLocalFont(data.font);
700
+ }
701
+ }
702
+ catch (e) {
703
+ console.warn('Local fonts API:', e);
704
+ }
705
+ }
706
+ _unbusy(data = {}) {
707
+ this._observeDemandCompletion(data.requestId, data.renderEpoch, data.painted === true || data.images != null);
708
+ this._prepareForce = true;
709
+ this._finishWorkerSlot();
710
+ }
711
+ _finishWorkerSlot() {
712
+ if (this._pendingDemandTimes.length > 0) {
713
+ if (this._pendingDemandTimes.length > 1) {
714
+ const latestDemand = this._pendingDemandTimes[this._pendingDemandTimes.length - 1];
715
+ latestDemand.force = latestDemand.force || this._pendingDemandTimes.some((demand) => demand.force);
716
+ this._pendingDemandTimes.length = 0;
717
+ this._pendingDemandTimes.push(latestDemand);
718
+ }
719
+ const nextDemand = this._pendingDemandTimes.shift();
720
+ if (nextDemand) {
721
+ if (!this._tryPresentPreparedDemand(nextDemand)) {
722
+ this._demandRender(nextDemand);
723
+ return;
724
+ }
725
+ }
726
+ }
727
+ this.busy = false;
728
+ this._primePreparedFrames(this._currentExactFrameMediaTime());
729
+ this._dispatchNextPreparation();
730
+ this._dispatchReadyWhenFrameBufferFilled();
731
+ }
732
+ _tryPresentPreparedDemand(metadata) {
733
+ if (!this._frameTimeline ||
734
+ this.framePrefetch <= 0 ||
735
+ this._isVideoPausedForWorker() ||
736
+ metadata.presentationId == null) {
737
+ return false;
738
+ }
739
+ if (isStalePresentation(metadata.presentationId, this._latestPresentationId))
740
+ return true;
741
+ const frameIndex = presentedFrameIndex(this._frameTimeline, metadata.mediaTime);
742
+ if (frameIndex < 0 || this._lastPresentedFrameIndex == null)
743
+ return false;
744
+ if (frameIndex !== this._lastPresentedFrameIndex)
745
+ return true;
746
+ if (metadata.width !== this._videoWidth || metadata.height !== this._videoHeight)
747
+ return false;
748
+ const prepared = this._preparedFrames.get(frameIndex);
749
+ if (!prepared)
750
+ return false;
751
+ this._preparedFrames.delete(frameIndex);
752
+ if (prepared.width !== this._canvasctrl.width || prepared.height !== this._canvasctrl.height) {
753
+ this._disposePreparedFrame(prepared);
754
+ this._prepareForce = true;
755
+ return false;
756
+ }
757
+ this._presentPreparedFrame(prepared, metadata.presentationId, metadata.expectedDisplayTime);
758
+ return true;
759
+ }
760
+ _dispatchReadyWhenFrameBufferFilled() {
761
+ if (!this._frameBufferReadyEvent ||
762
+ this.busy ||
763
+ this._pendingDemandTimes.length > 0 ||
764
+ this._prepareQueue.length > 0 ||
765
+ this._prepareRequests.size > 0) {
766
+ return;
767
+ }
768
+ const event = this._frameBufferReadyEvent;
769
+ this._frameBufferReadyEvent = null;
770
+ this.dispatchEvent(new CustomEvent(event));
771
+ }
772
+ _startRefreshSampling() {
773
+ if (this._refreshRafHandle != null ||
774
+ !this._frameTimeline ||
775
+ !this._canvasParent ||
776
+ typeof requestAnimationFrame !== 'function') {
777
+ return;
778
+ }
779
+ const sample = (time) => {
780
+ if (this._destroyed)
781
+ return;
782
+ if (Number.isFinite(this._lastRefreshRafTime)) {
783
+ const interval = time - this._lastRefreshRafTime;
784
+ if (interval >= 3 && interval <= 50) {
785
+ this._refreshSamples.push(interval);
786
+ if (this._refreshSamples.length > 48)
787
+ this._refreshSamples.shift();
788
+ }
789
+ }
790
+ this._lastRefreshRafTime = time;
791
+ this._refreshRafHandle = requestAnimationFrame(sample);
792
+ };
793
+ this._refreshRafHandle = requestAnimationFrame(sample);
794
+ }
795
+ _syncStagedCanvasLayout(stage) {
796
+ stage.style.display = 'block';
797
+ stage.style.position = 'absolute';
798
+ stage.style.pointerEvents = 'none';
799
+ stage.style.top = this._canvas.style.top;
800
+ stage.style.left = this._canvas.style.left;
801
+ stage.style.width = this._canvas.style.width;
802
+ stage.style.height = this._canvas.style.height;
803
+ stage.style.willChange = 'opacity';
804
+ stage.style.zIndex = '0';
805
+ }
806
+ _releaseGPUStage(stage) {
807
+ if (this._rendererType !== 'webgpu')
808
+ return;
809
+ this._gpuRenderer?.releaseCanvas(stage);
810
+ }
811
+ _removeStagedCanvas(stage) {
812
+ for (const animation of stage.getAnimations())
813
+ animation.cancel();
814
+ this._releaseGPUStage(stage);
815
+ stage.remove();
816
+ if (this._committedStage === stage)
817
+ this._committedStage = null;
818
+ this._stagedCanvases.delete(stage);
819
+ this._stageFrameIndices.delete(stage);
820
+ this._stageDisplayTimes.delete(stage);
821
+ }
822
+ _currentExactFrameIndex() {
823
+ if (!this._frameTimeline || !this._video)
824
+ return undefined;
825
+ if (!this._isVideoPausedForWorker() && this._lastPresentedFrameIndex != null) {
826
+ return this._lastPresentedFrameIndex;
827
+ }
828
+ return presentedFrameIndex(this._frameTimeline, this._video.currentTime);
829
+ }
830
+ _currentExactFrameMediaTime() {
831
+ const index = this._currentExactFrameIndex();
832
+ if (index != null && this._frameTimeline)
833
+ return this._frameTimeline[index];
834
+ return this._video?.currentTime ?? 0;
835
+ }
836
+ /** Make a freshly painted base canvas visible without discarding future prefetch. */
837
+ _activateBaseCanvas(presentedIndex) {
838
+ if (!this._canvas)
839
+ return;
840
+ // A compositor-scheduled frame can become visible before its RVFC arrives.
841
+ // Never let an older demand response roll that already-visible frame back.
842
+ if (presentedIndex != null) {
843
+ const now = performance.now();
844
+ let visibleIndex = this._committedStage ? this._stageFrameIndices.get(this._committedStage) : undefined;
845
+ for (const stage of this._stagedCanvases) {
846
+ const boundary = this._stageDisplayTimes.get(stage);
847
+ const index = this._stageFrameIndices.get(stage);
848
+ if (boundary != null && boundary <= now && index != null && (visibleIndex == null || index > visibleIndex)) {
849
+ visibleIndex = index;
850
+ }
851
+ }
852
+ if (visibleIndex != null && visibleIndex > presentedIndex)
853
+ return;
854
+ }
855
+ for (const animation of this._canvas.getAnimations())
856
+ animation.cancel();
857
+ this._canvas.style.opacity = '1';
858
+ const retainedStages = new Set();
859
+ for (const [index, frame] of [...this._preparedFrames]) {
860
+ const frameIndex = frame.index ?? index;
861
+ if (presentedIndex != null && frameIndex > presentedIndex && frame.stage) {
862
+ for (const animation of frame.animations ?? [])
863
+ animation.cancel();
864
+ frame.animations = undefined;
865
+ frame.scheduled = false;
866
+ frame.committed = false;
867
+ frame.stage.style.opacity = '0';
868
+ retainedStages.add(frame.stage);
869
+ continue;
870
+ }
871
+ this._disposePreparedFrame(frame);
872
+ this._preparedFrames.delete(index);
873
+ }
874
+ this._scheduledPreparedFrame = null;
875
+ for (const stage of [...this._stagedCanvases]) {
876
+ if (!retainedStages.has(stage))
877
+ this._removeStagedCanvas(stage);
878
+ }
879
+ this._committedStage = null;
880
+ this._scheduleNextPreparedFrame();
881
+ }
882
+ _activateBaseCanvasAfterGPUWork(renderEpoch = this._renderEpoch, presentedIndex) {
883
+ if (this._rendererType !== 'webgpu' || !(this._gpuRenderer instanceof WebGPURenderer)) {
884
+ this._activateBaseCanvas(presentedIndex);
885
+ return;
886
+ }
887
+ const renderer = this._gpuRenderer;
888
+ void renderer.submittedWorkDone().then(() => {
889
+ if (this._destroyed || renderEpoch !== this._renderEpoch)
890
+ return;
891
+ this._activateBaseCanvas(presentedIndex);
892
+ }, () => {
893
+ // Keep the last committed stage visible when the GPU device/queue is lost.
894
+ });
895
+ }
896
+ _disposePreparedFrame(frame) {
897
+ if (this._scheduledPreparedFrame === frame)
898
+ this._scheduledPreparedFrame = null;
899
+ for (const animation of frame.animations ?? [])
900
+ animation.cancel();
901
+ frame.animations = undefined;
902
+ frame.scheduled = false;
903
+ frame.bitmap?.close();
904
+ frame.bitmap = undefined;
905
+ if (frame.stage) {
906
+ this._removeStagedCanvas(frame.stage);
907
+ frame.stage = undefined;
908
+ }
909
+ }
910
+ _stagePreparedFrame(index, frame, allowWebGPU = true) {
911
+ if (!this._canvasParent || !frame.bitmap || this._destroyed)
912
+ return;
913
+ let stage = document.createElement('canvas');
914
+ stage.width = frame.width;
915
+ stage.height = frame.height;
916
+ this._syncStagedCanvasLayout(stage);
917
+ stage.style.opacity = '0';
918
+ frame.index = index;
919
+ frame.stage = stage;
920
+ frame.ready = false;
921
+ this._stagedCanvases.add(stage);
922
+ this._stageFrameIndices.set(stage, index);
923
+ let rendered = false;
924
+ const webgpuRenderer = allowWebGPU && this._rendererType === 'webgpu' && this._gpuRenderer instanceof WebGPURenderer
925
+ ? this._gpuRenderer
926
+ : null;
927
+ if (webgpuRenderer) {
928
+ // A WebGPU canvas is a presentation surface, not retained bitmap storage.
929
+ // Connect it before submitting work so Chromium cannot discard a frame
930
+ // rendered into a detached/never-composited swap chain.
931
+ this._canvasParent.appendChild(stage);
932
+ try {
933
+ rendered = webgpuRenderer.renderBitmapToCanvas(stage, frame.bitmap, frame.width, frame.height);
934
+ }
935
+ catch {
936
+ rendered = false;
937
+ }
938
+ }
939
+ if (!rendered) {
940
+ // A synchronized context guarantees drawImage has entered the canvas
941
+ // presentation pipeline before this hidden stage is compositor-scheduled.
942
+ // desynchronized:true can expose a newly appended but not-yet-painted layer.
943
+ const context = stage.getContext('2d', { alpha: true });
944
+ if (!context) {
945
+ this._removeStagedCanvas(stage);
946
+ stage = document.createElement('canvas');
947
+ stage.width = frame.width;
948
+ stage.height = frame.height;
949
+ this._syncStagedCanvasLayout(stage);
950
+ stage.style.opacity = '0';
951
+ frame.stage = stage;
952
+ this._stagedCanvases.add(stage);
953
+ this._stageFrameIndices.set(stage, index);
954
+ const fallbackContext = stage.getContext('2d', { alpha: true });
955
+ if (!fallbackContext) {
956
+ this._removeStagedCanvas(stage);
957
+ frame.stage = undefined;
958
+ return;
959
+ }
960
+ fallbackContext.drawImage(frame.bitmap, 0, 0);
961
+ }
962
+ else {
963
+ context.drawImage(frame.bitmap, 0, 0);
964
+ }
965
+ frame.ready = true;
966
+ if (!stage.isConnected)
967
+ this._canvasParent.appendChild(stage);
968
+ }
969
+ frame.bitmap.close();
970
+ frame.bitmap = undefined;
971
+ const targetDisplayTime = this._predictedDisplayTimes.get(index);
972
+ if (Number.isFinite(targetDisplayTime)) {
973
+ frame.targetDisplayTime = targetDisplayTime;
974
+ }
975
+ if (webgpuRenderer && rendered) {
976
+ // WebGPU queue submission is ordered. The target is still multiple video
977
+ // frames ahead, and waiting on the whole queue creates a teardown race:
978
+ // a demand handoff can unconfigure this context while the readiness
979
+ // promise is pending, rejecting it and starving prefetch forever.
980
+ frame.ready = true;
981
+ }
982
+ this._scheduleNextPreparedFrame();
983
+ }
984
+ _scheduleNextPreparedFrame() {
985
+ if (this._scheduledPreparedFrame || this._destroyed)
986
+ return;
987
+ let next;
988
+ let nextTime = Number.POSITIVE_INFINITY;
989
+ const now = performance.now();
990
+ for (const frame of this._preparedFrames.values()) {
991
+ const target = frame.targetDisplayTime;
992
+ if (!frame.stage ||
993
+ frame.committed ||
994
+ frame.scheduled ||
995
+ !Number.isFinite(target) ||
996
+ target <= now ||
997
+ target >= nextTime) {
998
+ continue;
999
+ }
1000
+ next = frame;
1001
+ nextTime = target;
1002
+ }
1003
+ if (next?.ready)
1004
+ this._schedulePreparedFrame(next, nextTime);
1005
+ }
1006
+ _commitPreparedStage(frame) {
1007
+ const stage = frame.stage;
1008
+ if (!stage || !this._stagedCanvases.has(stage) || this._destroyed)
1009
+ return;
1010
+ for (const animation of frame.animations ?? [])
1011
+ animation.cancel();
1012
+ frame.animations = undefined;
1013
+ frame.scheduled = false;
1014
+ frame.committed = true;
1015
+ if (this._scheduledPreparedFrame === frame)
1016
+ this._scheduledPreparedFrame = null;
1017
+ for (const animation of this._canvas.getAnimations())
1018
+ animation.cancel();
1019
+ this._canvas.style.opacity = '0';
1020
+ stage.style.opacity = '1';
1021
+ this._committedStage = stage;
1022
+ const frameIndex = frame.index ?? this._stageFrameIndices.get(stage);
1023
+ for (const candidate of [...this._stagedCanvases]) {
1024
+ if (candidate === stage)
1025
+ continue;
1026
+ for (const animation of candidate.getAnimations())
1027
+ animation.cancel();
1028
+ const candidateIndex = this._stageFrameIndices.get(candidate);
1029
+ const shouldRetire = frame.replaceAll || frameIndex == null || candidateIndex == null || candidateIndex < frameIndex;
1030
+ if (shouldRetire) {
1031
+ this._removeStagedCanvas(candidate);
1032
+ }
1033
+ else {
1034
+ candidate.style.opacity = '0';
1035
+ }
1036
+ }
1037
+ this._scheduleNextPreparedFrame();
1038
+ }
1039
+ _schedulePreparedFrame(frame, targetDisplayTime) {
1040
+ const stage = frame.stage;
1041
+ frame.targetDisplayTime = targetDisplayTime;
1042
+ if (!stage ||
1043
+ !frame.ready ||
1044
+ frame.scheduled ||
1045
+ frame.committed ||
1046
+ this._scheduledPreparedFrame ||
1047
+ this._destroyed ||
1048
+ targetDisplayTime <= performance.now()) {
1049
+ return;
1050
+ }
1051
+ // Each swap independently hides every other layer. Do not form a single
1052
+ // predecessor chain: removing one skipped prefetched frame would otherwise
1053
+ // cancel the only animation capable of hiding its predecessor. Future and
1054
+ // unscheduled stages are hidden too, but retained for their own later show.
1055
+ const previousStages = [...this._stagedCanvases].filter((candidate) => candidate !== stage);
1056
+ this._stageDisplayTimes.set(stage, targetDisplayTime);
1057
+ const performanceTime = performance.now();
1058
+ const compositorSwapTime = targetDisplayTime - compositorScheduleLeadMs(estimateRefreshIntervalMs(this._refreshSamples ?? []));
1059
+ const documentTime = Number(document.timeline?.currentTime);
1060
+ const hasDocumentTime = Number.isFinite(documentTime);
1061
+ const sharedStartTime = hasDocumentTime ? documentTime + (compositorSwapTime - performanceTime) : undefined;
1062
+ const animationOptions = {
1063
+ delay: hasDocumentTime ? 0 : Math.max(0, compositorSwapTime - performanceTime),
1064
+ // A near-zero positive interval keeps the swap compositor-scheduled while
1065
+ // allowing its cleanup promise to run in the same refresh. A full 1 ms
1066
+ // interval can survive until the next paint when animation composite order
1067
+ // temporarily favors an older stage.
1068
+ duration: 0.001,
1069
+ easing: 'steps(1, jump-start)',
1070
+ fill: 'forwards'
1071
+ };
1072
+ const hiddenLayers = [this._canvas, ...previousStages];
1073
+ let showAnimation = null;
1074
+ const hideAnimations = [];
1075
+ try {
1076
+ showAnimation = stage.animate([{ opacity: '0' }, { opacity: '1' }], animationOptions);
1077
+ for (const layer of hiddenLayers) {
1078
+ hideAnimations.push(layer.animate([{ opacity: '1' }, { opacity: '0' }], animationOptions));
1079
+ }
1080
+ }
1081
+ catch {
1082
+ showAnimation?.cancel();
1083
+ for (const animation of hideAnimations)
1084
+ animation.cancel();
1085
+ return;
1086
+ }
1087
+ if (!showAnimation)
1088
+ return;
1089
+ if (sharedStartTime != null) {
1090
+ showAnimation.startTime = sharedStartTime;
1091
+ for (const animation of hideAnimations)
1092
+ animation.startTime = sharedStartTime;
1093
+ }
1094
+ frame.scheduled = true;
1095
+ this._scheduledPreparedFrame = frame;
1096
+ const swapAnimations = [showAnimation, ...hideAnimations];
1097
+ frame.animations = swapAnimations;
1098
+ const releaseSwapAnimations = () => {
1099
+ for (const animation of swapAnimations)
1100
+ animation.cancel();
1101
+ if (frame.animations === swapAnimations)
1102
+ frame.animations = undefined;
1103
+ };
1104
+ // The show animation is the authoritative commit. Once it completes, make
1105
+ // its state explicit, release this swap's fill animations, and remove all
1106
+ // older layers. A later prefetched swap may already have its own independent
1107
+ // hide animation on this stage; it is intentionally left untouched.
1108
+ void showAnimation.finished.then(() => {
1109
+ if (frame.animations !== swapAnimations)
1110
+ return;
1111
+ if (!frame.committed)
1112
+ this._commitPreparedStage(frame);
1113
+ releaseSwapAnimations();
1114
+ if (this._scheduledPreparedFrame === frame)
1115
+ this._scheduledPreparedFrame = null;
1116
+ this._scheduleNextPreparedFrame();
1117
+ }, () => {
1118
+ if (frame.animations !== swapAnimations)
1119
+ return;
1120
+ releaseSwapAnimations();
1121
+ if (this._scheduledPreparedFrame === frame)
1122
+ this._scheduledPreparedFrame = null;
1123
+ frame.scheduled = false;
1124
+ if (!frame.committed)
1125
+ this._scheduleNextPreparedFrame();
1126
+ });
1127
+ }
1128
+ _recordPresentationClock(frameIndex, mediaTime, expectedDisplayTime) {
1129
+ const timeline = this._frameTimeline;
1130
+ const playbackRate = this._videoPlaybackRateForWorker();
1131
+ if (timeline && frameIndex >= 0) {
1132
+ this._predictedDisplayTimes.delete(frameIndex);
1133
+ }
1134
+ if (!timeline ||
1135
+ frameIndex < 0 ||
1136
+ frameIndex + 1 >= timeline.length ||
1137
+ !Number.isFinite(expectedDisplayTime) ||
1138
+ playbackRate <= 0) {
1139
+ return;
1140
+ }
1141
+ if ((Number.isFinite(this._lastClockMediaTime) &&
1142
+ (mediaTime <= this._lastClockMediaTime || Math.abs(mediaTime - this._lastClockMediaTime) > 0.5)) ||
1143
+ (Number.isFinite(this._lastClockPlaybackRate) && this._lastClockPlaybackRate !== playbackRate)) {
1144
+ this._displayClockOffsets.length = 0;
1145
+ this._predictedDisplayTimes.clear();
1146
+ this._displayGridAnchorMs = undefined;
1147
+ }
1148
+ this._lastClockMediaTime = mediaTime;
1149
+ this._lastClockPlaybackRate = playbackRate;
1150
+ this._displayGridAnchorMs = expectedDisplayTime;
1151
+ const frameMediaTime = timeline[frameIndex];
1152
+ this._displayClockOffsets.push(expectedDisplayTime - (frameMediaTime * 1000) / playbackRate);
1153
+ if (this._displayClockOffsets.length > 12)
1154
+ this._displayClockOffsets.shift();
1155
+ const refreshInterval = estimateRefreshIntervalMs(this._refreshSamples);
1156
+ const targetDisplayTime = predictFrameDisplayTimeMs(timeline[frameIndex + 1], playbackRate, this._displayClockOffsets, this._displayGridAnchorMs, refreshInterval);
1157
+ if (!Number.isFinite(targetDisplayTime))
1158
+ return;
1159
+ this._predictedDisplayTimes.set(frameIndex + 1, targetDisplayTime);
1160
+ const prepared = this._preparedFrames.get(frameIndex + 1);
1161
+ if (prepared)
1162
+ prepared.targetDisplayTime = targetDisplayTime;
1163
+ this._scheduleNextPreparedFrame();
1164
+ }
1165
+ _clearPreparedFrames(preservePresentation = true) {
1166
+ let preservedStage = preservePresentation ? this._committedStage : null;
1167
+ if (preservePresentation) {
1168
+ const now = performance.now();
1169
+ let latestBoundary = preservedStage ? (this._stageDisplayTimes.get(preservedStage) ?? -Infinity) : -Infinity;
1170
+ for (const stage of this._stagedCanvases) {
1171
+ const boundary = this._stageDisplayTimes.get(stage);
1172
+ if (boundary != null && boundary <= now && boundary >= latestBoundary) {
1173
+ preservedStage = stage;
1174
+ latestBoundary = boundary;
1175
+ }
1176
+ }
1177
+ }
1178
+ const preservedFrameIndex = preservedStage ? this._stageFrameIndices.get(preservedStage) : undefined;
1179
+ const preservedDisplayTime = preservedStage ? this._stageDisplayTimes.get(preservedStage) : undefined;
1180
+ this._scheduledPreparedFrame = null;
1181
+ for (const frame of this._preparedFrames.values()) {
1182
+ if (frame.stage === preservedStage) {
1183
+ for (const animation of frame.animations ?? [])
1184
+ animation.cancel();
1185
+ frame.animations = undefined;
1186
+ frame.scheduled = false;
1187
+ frame.bitmap?.close();
1188
+ frame.bitmap = undefined;
1189
+ continue;
1190
+ }
1191
+ this._disposePreparedFrame(frame);
1192
+ }
1193
+ this._preparedFrames.clear();
1194
+ for (const stage of [...this._stagedCanvases]) {
1195
+ if (stage !== preservedStage)
1196
+ this._removeStagedCanvas(stage);
1197
+ }
1198
+ this._stagedCanvases.clear();
1199
+ this._stageFrameIndices.clear();
1200
+ this._stageDisplayTimes.clear();
1201
+ for (const animation of this._canvas?.getAnimations?.() ?? [])
1202
+ animation.cancel();
1203
+ if (preservedStage?.isConnected) {
1204
+ for (const animation of preservedStage.getAnimations())
1205
+ animation.cancel();
1206
+ preservedStage.style.opacity = '1';
1207
+ this._stagedCanvases.add(preservedStage);
1208
+ if (preservedFrameIndex != null)
1209
+ this._stageFrameIndices.set(preservedStage, preservedFrameIndex);
1210
+ if (preservedDisplayTime != null)
1211
+ this._stageDisplayTimes.set(preservedStage, preservedDisplayTime);
1212
+ if (this._canvas)
1213
+ this._canvas.style.opacity = '0';
1214
+ this._committedStage = preservedStage;
1215
+ }
1216
+ else {
1217
+ if (preservedStage)
1218
+ this._removeStagedCanvas(preservedStage);
1219
+ this._committedStage = null;
1220
+ if (this._canvas)
1221
+ this._canvas.style.opacity = '1';
1222
+ }
1223
+ this._predictedDisplayTimes.clear();
1224
+ this._displayClockOffsets.length = 0;
1225
+ this._displayGridAnchorMs = undefined;
1226
+ this._lastClockMediaTime = undefined;
1227
+ this._lastClockPlaybackRate = undefined;
1228
+ this._lastPresentedFrameIndex = undefined;
1229
+ this._prepareQueue.length = 0;
1230
+ this._prepareRequests.clear();
1231
+ }
1232
+ _primePreparedFrames(mediaTime) {
1233
+ const timeline = this._frameTimeline;
1234
+ if (!timeline ||
1235
+ timeline.length === 0 ||
1236
+ this.framePrefetch <= 0 ||
1237
+ this._destroyed) {
1238
+ return;
1239
+ }
1240
+ const currentIndex = presentedFrameIndex(timeline, mediaTime);
1241
+ const lastIndex = Math.min(timeline.length - 1, currentIndex + this.framePrefetch);
1242
+ for (const [index, frame] of this._preparedFrames) {
1243
+ if (index < currentIndex || index > lastIndex) {
1244
+ // A scheduled stage may already be the compositor's visible frame even
1245
+ // when Chromium skips/delays its RVFC. Removing it here produces a blank
1246
+ // refresh. Its successor's completed hide animation owns stage cleanup.
1247
+ if ((frame.scheduled || frame.committed) && index < currentIndex) {
1248
+ frame.bitmap?.close();
1249
+ frame.bitmap = undefined;
1250
+ }
1251
+ else {
1252
+ this._disposePreparedFrame(frame);
1253
+ }
1254
+ this._preparedFrames.delete(index);
1255
+ }
1256
+ }
1257
+ const queued = this._prepareQueue.filter((index) => index > currentIndex && index <= lastIndex);
1258
+ this._prepareQueue.length = 0;
1259
+ this._prepareQueue.push(...queued);
1260
+ const requested = new Set();
1261
+ for (const request of this._prepareRequests.values())
1262
+ requested.add(request.index);
1263
+ for (const index of this._prepareQueue)
1264
+ requested.add(index);
1265
+ for (let index = currentIndex + 1; index <= lastIndex; index++) {
1266
+ if (!this._preparedFrames.has(index) && !requested.has(index))
1267
+ this._prepareQueue.push(index);
1268
+ }
1269
+ }
1270
+ _dispatchNextPreparation() {
1271
+ if (this.busy || !this._workerReady || !this._frameTimeline || this.framePrefetch <= 0)
1272
+ return;
1273
+ let index;
1274
+ while ((index = this._prepareQueue.shift()) != null) {
1275
+ if (!this._preparedFrames.has(index))
1276
+ break;
1277
+ }
1278
+ if (index == null)
1279
+ return;
1280
+ const time = subtitleTimeForFrame(this._frameTimeline, index);
1281
+ if (!Number.isFinite(time))
1282
+ return;
1283
+ const prepareId = this._nextPrepareId++;
1284
+ this._prepareRequests.set(prepareId, { index, renderEpoch: this._renderEpoch });
1285
+ this.busy = true;
1286
+ const force = this._prepareForce;
1287
+ this._prepareForce = false;
1288
+ this._postWorkerMessage('prepare', {
1289
+ time: time + this.timeOffset,
1290
+ prepareId,
1291
+ renderEpoch: this._renderEpoch,
1292
+ force
1293
+ });
1294
+ }
1295
+ _preparedFrame(data) {
1296
+ const request = this._prepareRequests.get(data.prepareId);
1297
+ this._prepareRequests.delete(data.prepareId);
1298
+ if (request?.presentation) {
1299
+ const presentation = request.presentation;
1300
+ if (data.bitmap && request.renderEpoch === this._renderEpoch && data.renderEpoch === this._renderEpoch) {
1301
+ this._presentPreparedFrame({
1302
+ width: data.width ?? this._canvasctrl.width,
1303
+ height: data.height ?? this._canvasctrl.height,
1304
+ bitmap: data.bitmap,
1305
+ index: request.index
1306
+ }, presentation.presentationId, presentation.expectedDisplayTime);
1307
+ this._prepareForce = true;
1308
+ this._finishWorkerSlot();
1309
+ return;
1310
+ }
1311
+ data.bitmap?.close();
1312
+ if (request.renderEpoch === this._renderEpoch && data.renderEpoch === this._renderEpoch) {
1313
+ this._demandRender({ ...presentation, preparedPresentationAttempted: true });
1314
+ return;
1315
+ }
1316
+ this._prepareForce = true;
1317
+ this._finishWorkerSlot();
1318
+ return;
1319
+ }
1320
+ const currentIndex = this._currentExactFrameIndex() ?? -1;
1321
+ if (request &&
1322
+ data.bitmap &&
1323
+ request.renderEpoch === this._renderEpoch &&
1324
+ data.renderEpoch === this._renderEpoch &&
1325
+ request.index >= currentIndex) {
1326
+ const previous = this._preparedFrames.get(request.index);
1327
+ if (previous)
1328
+ this._disposePreparedFrame(previous);
1329
+ const prepared = {
1330
+ width: data.width ?? this._canvasctrl.width,
1331
+ height: data.height ?? this._canvasctrl.height,
1332
+ bitmap: data.bitmap
1333
+ };
1334
+ this._stagePreparedFrame(request.index, prepared);
1335
+ this._preparedFrames.set(request.index, prepared);
1336
+ this._scheduleNextPreparedFrame();
1337
+ }
1338
+ else {
1339
+ data.bitmap?.close();
1340
+ }
1341
+ // Preparations share libass' changed-frame baseline with demand renders but
1342
+ // paint to a different canvas; force complete snapshots afterwards.
1343
+ this._prepareForce = true;
1344
+ this._finishWorkerSlot();
1345
+ }
1346
+ _presentPreparedFrame(frame, presentationId, expectedDisplayTime) {
1347
+ if (!this._activatePresentation(presentationId)) {
1348
+ // Scheduling happens on the display clock before RVFC validation. If a
1349
+ // newer callback has already advanced the presentation watermark, this
1350
+ // stage can still be the compositor's current frame. Never tear down a
1351
+ // scheduled stage from a stale callback; the next atomic swap removes it.
1352
+ if (frame.stage && (frame.scheduled || frame.committed || this._committedStage === frame.stage)) {
1353
+ frame.bitmap?.close();
1354
+ frame.bitmap = undefined;
1355
+ }
1356
+ else {
1357
+ this._disposePreparedFrame(frame);
1358
+ }
1359
+ return;
1360
+ }
1361
+ const { bitmap, width, height, stage } = frame;
1362
+ if (stage) {
1363
+ if (!frame.committed || this._committedStage !== stage) {
1364
+ for (const animation of frame.animations ?? []) {
1365
+ try {
1366
+ animation.finish();
1367
+ }
1368
+ catch {
1369
+ animation.cancel();
1370
+ }
1371
+ }
1372
+ this._commitPreparedStage(frame);
1373
+ }
1374
+ this._stageDisplayTimes.set(stage, Number.isFinite(expectedDisplayTime) ? expectedDisplayTime : performance.now());
1375
+ bitmap?.close();
1376
+ frame.bitmap = undefined;
1377
+ return;
1378
+ }
1379
+ if (!bitmap)
1380
+ return;
1381
+ try {
1382
+ if (this._gpuRenderer) {
1383
+ let painted = false;
1384
+ try {
1385
+ painted = this._gpuRenderer.renderBitmaps([{ image: bitmap, x: 0, y: 0 }], width, height);
1386
+ }
1387
+ catch (error) {
1388
+ console.warn('[AkariSub] GPU prepared-frame presentation failed; using Canvas2D fallback.', error);
1389
+ }
1390
+ if (painted === false) {
1391
+ frame.bitmap = bitmap;
1392
+ frame.replaceAll = true;
1393
+ this._stagePreparedFrame(-1, frame, false);
1394
+ if (frame.stage)
1395
+ this._commitPreparedStage(frame);
1396
+ return;
1397
+ }
1398
+ this._activateBaseCanvasAfterGPUWork(this._renderEpoch, frame.index);
1399
+ return;
1400
+ }
1401
+ if (this._ctx) {
1402
+ this._ctx.clearRect(0, 0, width, height);
1403
+ this._ctx.drawImage(bitmap, 0, 0);
1404
+ this._activateBaseCanvas(frame.index);
1405
+ return;
1406
+ }
1407
+ this._postWorkerMessage('presentFrame', { bitmap, presentationId, renderEpoch: this._renderEpoch, frameIndex: frame.index }, [bitmap]);
1408
+ }
1409
+ finally {
1410
+ if (this._ctx || this._gpuRenderer)
1411
+ bitmap.close();
1412
+ }
1413
+ }
1414
+ _presentedFrame(data) {
1415
+ if ((data.renderEpoch != null && data.renderEpoch !== this._renderEpoch) ||
1416
+ isStalePresentation(data.presentationId, this._latestPresentationId)) {
1417
+ return;
1418
+ }
1419
+ this._activateBaseCanvas(data.frameIndex);
1420
+ }
1421
+ // Advance the presentation watermark only when a frame enters the pipeline,
1422
+ // not when an RVFC is merely queued (avoids starving the in-flight render).
1423
+ _activatePresentation(presentationId) {
1424
+ if (isStalePresentation(presentationId, this._latestPresentationId))
1425
+ return false;
1426
+ this._latestPresentationId = presentationId;
1427
+ if (this._workerReady)
1428
+ this._postWorkerMessage('presentation', { presentationId });
1429
+ return true;
1430
+ }
1431
+ _bumpRenderEpoch() {
1432
+ this._renderEpoch++;
1433
+ this._pendingDemandTimes.length = 0;
1434
+ this._demandTimings.clear();
1435
+ this._clearPreparedFrames();
1436
+ this._prepareForce = true;
1437
+ }
1438
+ _sendMutatingMessage(target, data = {}, transferable) {
1439
+ this._bumpRenderEpoch();
1440
+ void this.sendMessage(target, data, transferable).then(() => {
1441
+ this._syncVideoClock();
1442
+ });
1443
+ }
1444
+ _cancelRVFC() {
1445
+ this._rvfcGeneration++;
1446
+ if (this._video && this._rvfcHandle != null) {
1447
+ const cancelVideoFrameCallback = this._video.cancelVideoFrameCallback;
1448
+ if (typeof cancelVideoFrameCallback === 'function') {
1449
+ try {
1450
+ cancelVideoFrameCallback.call(this._video, this._rvfcHandle);
1451
+ }
1452
+ catch {
1453
+ // Some browser/polyfill combinations can throw for already-fired
1454
+ // handles. The generation guard below still rejects stale callbacks.
1455
+ }
1456
+ }
1457
+ }
1458
+ this._rvfcHandle = null;
1459
+ }
1460
+ _scheduleRVFC(video = this._video) {
1461
+ if (!this._onDemandRender || !video || this._destroyed)
1462
+ return;
1463
+ const requestVideoFrameCallback = video.requestVideoFrameCallback;
1464
+ if (typeof requestVideoFrameCallback !== 'function')
1465
+ return;
1466
+ const generation = this._rvfcGeneration;
1467
+ this._rvfcHandle = requestVideoFrameCallback.call(video, (now, metadata) => {
1468
+ if (this._video === video && generation === this._rvfcGeneration) {
1469
+ this._rvfcHandle = null;
1470
+ }
1471
+ if (this._destroyed || this._video !== video || generation !== this._rvfcGeneration)
1472
+ return;
1473
+ this._handleRVFC(now, metadata);
1474
+ });
1475
+ }
1476
+ _closeRenderImages(images) {
1477
+ for (const image of images) {
1478
+ if (image.image instanceof ImageBitmap) {
1479
+ image.image.close();
1480
+ }
1481
+ }
1482
+ }
1483
+ _isVideoPausedForWorker() {
1484
+ if (!this._video)
1485
+ return true;
1486
+ return this._video.paused || this._video.ended || this._playstate;
1487
+ }
1488
+ _videoPlaybackRateForWorker() {
1489
+ const playbackRate = this._video?.playbackRate ?? 1;
1490
+ return Number.isFinite(playbackRate) ? playbackRate : 1;
1491
+ }
1492
+ _setVideoClockStateFromEvent(event) {
1493
+ if (!event || !this._video)
1494
+ return;
1495
+ switch (event.type) {
1496
+ case 'play':
1497
+ case 'playing':
1498
+ case 'canplay':
1499
+ this._playstate = false;
1500
+ break;
1501
+ case 'pause':
1502
+ case 'ended':
1503
+ case 'seeking':
1504
+ case 'waiting':
1505
+ case 'stalled':
1506
+ this._playstate = true;
1507
+ break;
1508
+ case 'seeked':
1509
+ this._playstate = this._video.paused || this._video.ended;
1510
+ break;
1511
+ }
1512
+ }
1513
+ _currentVideoTimeWithOffset() {
1514
+ const currentTime = this._video?.currentTime ?? 0;
1515
+ return (Number.isFinite(currentTime) ? currentTime : 0) + this.timeOffset;
1516
+ }
1517
+ _syncVideoClock(event) {
1518
+ if (!this._video || this._destroyed)
1519
+ return;
1520
+ this._setVideoClockStateFromEvent(event);
1521
+ const currentTime = this._currentVideoTimeWithOffset();
1522
+ const playbackRate = this._videoPlaybackRateForWorker();
1523
+ const isPaused = this._isVideoPausedForWorker();
1524
+ this.setCurrentTime(isPaused, currentTime, playbackRate);
1525
+ if (!this._onDemandRender)
1526
+ return;
1527
+ // RVFC renders ahead while the video is actively presenting frames. When
1528
+ // playback pauses, stalls, or seeks, RVFC may stop and the last ahead render
1529
+ // can be visibly in the future. Force an exact render at the displayed media
1530
+ // time for non-advancing states.
1531
+ const shouldRenderExactFrame = isPaused ||
1532
+ event?.type === 'pause' ||
1533
+ event?.type === 'seeking' ||
1534
+ event?.type === 'seeked' ||
1535
+ event?.type === 'waiting' ||
1536
+ event?.type === 'stalled' ||
1537
+ event?.type === 'ended';
1538
+ if (shouldRenderExactFrame) {
1539
+ this._bumpRenderEpoch();
1540
+ const presentationId = this._nextPresentationId++;
1541
+ // Pause/seek is terminal: supersede any speculative play-ahead immediately.
1542
+ this._activatePresentation(presentationId);
1543
+ this._requestDemandRender({
1544
+ mediaTime: currentTime - this.timeOffset,
1545
+ width: this._video.videoWidth || this._videoWidth || 0,
1546
+ height: this._video.videoHeight || this._videoHeight || 0,
1547
+ force: true,
1548
+ presentationId
1549
+ });
1550
+ }
1551
+ }
1552
+ _requestDemandRender(metadata) {
1553
+ if (!this._workerReady) {
1554
+ this._enqueueDemand(metadata);
1555
+ return;
1556
+ }
1557
+ if (this.busy) {
1558
+ this._enqueueDemand(metadata);
1559
+ }
1560
+ else {
1561
+ this.busy = true;
1562
+ this._demandRender(metadata);
1563
+ }
1564
+ }
1565
+ _enqueueDemand(metadata) {
1566
+ const queue = this._pendingDemandTimes;
1567
+ if (queue.length > 0) {
1568
+ const lastQueued = queue[queue.length - 1];
1569
+ if (Math.abs(lastQueued.mediaTime - metadata.mediaTime) > 0.25) {
1570
+ metadata.force = metadata.force || queue.some((demand) => demand.force);
1571
+ queue.length = 0;
1572
+ }
1573
+ }
1574
+ if (queue.length >= AkariSub.MAX_PENDING_DEMANDS) {
1575
+ const dropped = queue.shift();
1576
+ if (dropped?.force)
1577
+ metadata.force = true;
1578
+ }
1579
+ queue.push(metadata);
1580
+ }
1581
+ _handleRVFC(now, metadata) {
1582
+ if (this._destroyed)
1583
+ return;
1584
+ const presentationId = this._nextPresentationId++;
1585
+ // Keep the browser frame timestamp unmodified while queued; predict at dispatch.
1586
+ const isPaused = this._isVideoPausedForWorker();
1587
+ const expectedDisplayTime = Number.isFinite(metadata.expectedDisplayTime)
1588
+ ? metadata.expectedDisplayTime
1589
+ : Number.isFinite(metadata.presentationTime)
1590
+ ? metadata.presentationTime
1591
+ : now;
1592
+ const mediaTime = resolvePresentationMediaTime(metadata.mediaTime, this._video?.currentTime, !!this._frameTimeline?.length, this._frameTimeline?.mediaTimeOrigin, this._frameTimeline ?? undefined);
1593
+ const demandData = {
1594
+ mediaTime,
1595
+ width: metadata.width,
1596
+ height: metadata.height,
1597
+ expectedDisplayTime: isPaused ? undefined : expectedDisplayTime,
1598
+ presentationId
1599
+ };
1600
+ let presented = false;
1601
+ if (!isPaused && this._frameTimeline && this.framePrefetch > 0) {
1602
+ const frameIndex = presentedFrameIndex(this._frameTimeline, mediaTime);
1603
+ if (frameIndex >= 0)
1604
+ this._lastPresentedFrameIndex = frameIndex;
1605
+ const prepared = this._preparedFrames.get(frameIndex);
1606
+ if (prepared) {
1607
+ this._preparedFrames.delete(frameIndex);
1608
+ if (prepared.width === this._canvasctrl.width && prepared.height === this._canvasctrl.height) {
1609
+ this._presentPreparedFrame(prepared, presentationId, expectedDisplayTime);
1610
+ presented = true;
1611
+ }
1612
+ else {
1613
+ this._disposePreparedFrame(prepared);
1614
+ this._prepareForce = true;
1615
+ }
1616
+ }
1617
+ this._primePreparedFrames(mediaTime);
1618
+ this._recordPresentationClock(frameIndex, mediaTime, expectedDisplayTime);
1619
+ }
1620
+ if (!presented)
1621
+ this._requestDemandRender(demandData);
1622
+ this._dispatchNextPreparation();
1623
+ this._scheduleRVFC(this._video);
1624
+ }
1625
+ _observeDemandCompletion(requestId, renderEpoch, painted = false) {
1626
+ if (requestId == null)
1627
+ return;
1628
+ const timing = this._demandTimings.get(requestId);
1629
+ if (!timing)
1630
+ return;
1631
+ this._demandTimings.delete(requestId);
1632
+ if (!painted)
1633
+ return;
1634
+ if (renderEpoch != null && renderEpoch !== timing.renderEpoch)
1635
+ return;
1636
+ if (timing.renderEpoch !== this._renderEpoch || !this._adaptiveTiming)
1637
+ return;
1638
+ this._timingCompensationSeconds = updateTimingCompensation(this._timingCompensationSeconds, performance.now(), timing.dispatchedAt);
1639
+ }
1640
+ _demandRender(metadata) {
1641
+ if (metadata.width !== this._videoWidth || metadata.height !== this._videoHeight) {
1642
+ this._videoWidth = metadata.width;
1643
+ this._videoHeight = metadata.height;
1644
+ this.resize();
1645
+ }
1646
+ const isPaused = this._isVideoPausedForWorker();
1647
+ const presentationId = metadata.presentationId ?? this._nextPresentationId++;
1648
+ metadata.presentationId = presentationId;
1649
+ if (!isPaused && this._frameTimeline && this.framePrefetch > 0 && !metadata.preparedPresentationAttempted) {
1650
+ const frameIndex = presentedFrameIndex(this._frameTimeline, metadata.mediaTime);
1651
+ const renderTime = frameIndex >= 0 ? subtitleTimeForFrame(this._frameTimeline, frameIndex) : metadata.mediaTime;
1652
+ const prepareId = this._nextPrepareId++;
1653
+ this._prepareRequests.set(prepareId, {
1654
+ index: frameIndex,
1655
+ renderEpoch: this._renderEpoch,
1656
+ presentation: metadata
1657
+ });
1658
+ this._postWorkerMessage('prepare', {
1659
+ time: renderTime + this.timeOffset,
1660
+ prepareId,
1661
+ renderEpoch: this._renderEpoch,
1662
+ force: true
1663
+ });
1664
+ return;
1665
+ }
1666
+ const dispatchedAt = performance.now();
1667
+ const adaptiveLead = this._adaptiveTiming && !isPaused
1668
+ ? presentationLeadSeconds(dispatchedAt, metadata.expectedDisplayTime, this._timingCompensationSeconds)
1669
+ : 0;
1670
+ const predictedRenderTime = compensatedMediaTime(metadata.mediaTime, this._videoPlaybackRateForWorker(), this.renderAhead, adaptiveLead, isPaused);
1671
+ const renderTime = selectRenderMediaTime(this._frameTimeline, metadata.mediaTime, predictedRenderTime, isPaused);
1672
+ if (!this._activatePresentation(presentationId)) {
1673
+ this._finishWorkerSlot();
1674
+ return;
1675
+ }
1676
+ const requestId = this._nextDemandId++;
1677
+ if (this._adaptiveTiming && !isPaused) {
1678
+ this._demandTimings.set(requestId, {
1679
+ dispatchedAt,
1680
+ renderEpoch: this._renderEpoch
1681
+ });
1682
+ }
1683
+ this.sendMessage('demand', {
1684
+ time: renderTime + this.timeOffset,
1685
+ // Exact-timeline preparation mutates libass' change baseline; force a full frame.
1686
+ force: metadata.force || this._frameTimeline != null,
1687
+ requestId,
1688
+ renderEpoch: this._renderEpoch,
1689
+ presentationId
1690
+ });
1691
+ }
1692
+ _detachOffscreen() {
1693
+ if (!this._offscreenRender || this._ctx)
1694
+ return;
1695
+ this._canvas.remove();
1696
+ this._createCanvas();
1697
+ this._canvasctrl = this._canvas;
1698
+ this._ctx = this._canvasctrl.getContext('2d', { alpha: true });
1699
+ this.sendMessage('detachOffscreen');
1700
+ this.busy = false;
1701
+ this._pendingDemandTimes.length = 0;
1702
+ this.resize(0, 0, 0, 0, true);
1703
+ }
1704
+ _reAttachOffscreen() {
1705
+ if (!this._offscreenRender || !this._ctx)
1706
+ return;
1707
+ this._canvas.remove();
1708
+ this._createCanvas();
1709
+ this._canvasctrl = this._canvas.transferControlToOffscreen();
1710
+ this._ctx = false;
1711
+ this.sendMessage('offscreenCanvas', {}, [this._canvasctrl]);
1712
+ this.resize(0, 0, 0, 0, true);
1713
+ }
1714
+ _updateColorSpace() {
1715
+ ;
1716
+ this._video.requestVideoFrameCallback(() => {
1717
+ try {
1718
+ const frame = new globalThis.VideoFrame(this._video);
1719
+ this._videoColorSpace = webYCbCrMap[frame.colorSpace.matrix] ?? null;
1720
+ frame.close();
1721
+ this.sendMessage('getColorSpace');
1722
+ }
1723
+ catch (e) {
1724
+ console.warn(e);
1725
+ }
1726
+ });
1727
+ }
1728
+ _verifyColorSpace(data) {
1729
+ const { subtitleColorSpace, videoColorSpace = this._videoColorSpace } = data;
1730
+ if (!subtitleColorSpace || !videoColorSpace)
1731
+ return;
1732
+ if (subtitleColorSpace === videoColorSpace)
1733
+ return;
1734
+ this._detachOffscreen();
1735
+ const matrix = colorMatrixConversionMap[subtitleColorSpace]?.[videoColorSpace];
1736
+ if (matrix && this._ctx) {
1737
+ 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")`;
1738
+ }
1739
+ }
1740
+ _render(data) {
1741
+ try {
1742
+ if ((data.renderEpoch != null && data.renderEpoch !== this._renderEpoch) ||
1743
+ isStalePresentation(data.presentationId, this._latestPresentationId)) {
1744
+ this._closeRenderImages(data.images);
1745
+ return;
1746
+ }
1747
+ const dataWidth = data.width;
1748
+ const dataHeight = data.height;
1749
+ if (this.debug) {
1750
+ data.times.IPCTime = Date.now() - (data.times.JSRenderTime || 0);
1751
+ }
1752
+ const sizeChanged = this._canvasctrl.width !== dataWidth || this._canvasctrl.height !== dataHeight;
1753
+ if (sizeChanged) {
1754
+ this._canvasctrl.width = dataWidth;
1755
+ this._canvasctrl.height = dataHeight;
1756
+ this._lastRenderWidth = dataWidth;
1757
+ this._lastRenderHeight = dataHeight;
1758
+ if (this._gpuRenderer) {
1759
+ this._gpuRenderer.updateSize(dataWidth, dataHeight);
1760
+ }
1761
+ this._verifyColorSpace({ subtitleColorSpace: data.colorSpace });
1762
+ }
1763
+ if (this._gpuRenderer) {
1764
+ this._renderGPU(data);
1765
+ return;
1766
+ }
1767
+ if (!this._ctx)
1768
+ return;
1769
+ const ctx = this._ctx;
1770
+ const images = data.images;
1771
+ const imageCount = images.length;
1772
+ ctx.clearRect(0, 0, dataWidth, dataHeight);
1773
+ if (data.asyncRender) {
1774
+ for (let i = 0; i < imageCount; i++) {
1775
+ const image = images[i];
1776
+ if (image.image) {
1777
+ ctx.drawImage(image.image, image.x, image.y);
1778
+ image.image.close();
1779
+ }
1780
+ }
1781
+ }
1782
+ else {
1783
+ const hasAlphaBug = AkariSub._hasAlphaBug ?? false;
1784
+ const bufferCanvas = this._bufferCanvas;
1785
+ const bufferCtx = this._bufferCtx;
1786
+ for (let i = 0; i < imageCount; i++) {
1787
+ const image = images[i];
1788
+ if (image.image) {
1789
+ const imgW = image.w;
1790
+ const imgH = image.h;
1791
+ const rawImage = image.image;
1792
+ const rawData = rawImage instanceof Uint8ClampedArray
1793
+ ? rawImage
1794
+ : rawImage instanceof Uint8Array
1795
+ ? new Uint8ClampedArray(rawImage.buffer, rawImage.byteOffset, rawImage.byteLength)
1796
+ : new Uint8ClampedArray(rawImage);
1797
+ const fixedData = fixAlpha(rawData, hasAlphaBug);
1798
+ if (bufferCanvas.width !== imgW || bufferCanvas.height !== imgH) {
1799
+ bufferCanvas.width = imgW;
1800
+ bufferCanvas.height = imgH;
1801
+ }
1802
+ bufferCtx.putImageData(new ImageData(fixedData, imgW, imgH), 0, 0);
1803
+ ctx.drawImage(bufferCanvas, image.x, image.y);
1804
+ }
1805
+ }
1806
+ }
1807
+ this._activateBaseCanvas(this._currentExactFrameIndex());
1808
+ if (this.debug) {
1809
+ data.times.JSRenderTime = Date.now() - (data.times.JSRenderTime || 0) - (data.times.IPCTime || 0);
1810
+ let total = 0;
1811
+ const count = data.times.bitmaps || imageCount;
1812
+ delete data.times.bitmaps;
1813
+ for (const key in data.times) {
1814
+ total += data.times[key] || 0;
1815
+ }
1816
+ console.log('Bitmaps: ' + count + ' Total: ' + (total | 0) + 'ms', data.times);
1817
+ }
1818
+ }
1819
+ finally {
1820
+ this._unbusy(data);
1821
+ }
1822
+ }
1823
+ _renderGPU(data) {
1824
+ const renderer = this._gpuRenderer;
1825
+ if (!renderer)
1826
+ return;
1827
+ const presentedIndex = this._currentExactFrameIndex();
1828
+ if (data.images.length === 0) {
1829
+ let painted = false;
1830
+ try {
1831
+ painted = renderer.clear();
1832
+ }
1833
+ catch (error) {
1834
+ console.warn('[AkariSub] GPU clear failed; preserving the last subtitle frame.', error);
1835
+ }
1836
+ if (painted !== false) {
1837
+ this._activateBaseCanvasAfterGPUWork(data.renderEpoch, presentedIndex);
1838
+ }
1839
+ return;
1840
+ }
1841
+ let painted;
1842
+ if (data.asyncRender) {
1843
+ const bitmapImages = this._gpuBitmapImages;
1844
+ let bitmapCount = 0;
1845
+ for (let i = 0; i < data.images.length; i++) {
1846
+ const img = data.images[i];
1847
+ if (!(img.image instanceof ImageBitmap))
1848
+ continue;
1849
+ const target = bitmapImages[bitmapCount] || (bitmapImages[bitmapCount] = { image: img.image, x: 0, y: 0 });
1850
+ target.image = img.image;
1851
+ target.x = img.x;
1852
+ target.y = img.y;
1853
+ bitmapCount++;
1854
+ }
1855
+ bitmapImages.length = bitmapCount;
1856
+ try {
1857
+ painted = renderer.renderBitmaps(bitmapImages, this._canvasctrl.width, this._canvasctrl.height);
1858
+ }
1859
+ catch (error) {
1860
+ painted = false;
1861
+ console.warn('[AkariSub] GPU bitmap render failed; preserving the last subtitle frame.', error);
1862
+ }
1863
+ finally {
1864
+ for (const img of data.images) {
1865
+ if (img.image instanceof ImageBitmap) {
1866
+ img.image.close();
1867
+ }
1868
+ }
1869
+ }
1870
+ }
1871
+ else {
1872
+ try {
1873
+ painted = renderer.render(data.images, this._canvasctrl.width, this._canvasctrl.height);
1874
+ }
1875
+ catch (error) {
1876
+ painted = false;
1877
+ console.warn('[AkariSub] GPU render failed; preserving the last subtitle frame.', error);
1878
+ }
1879
+ }
1880
+ if (painted !== false) {
1881
+ this._activateBaseCanvasAfterGPUWork(data.renderEpoch, presentedIndex);
1882
+ }
1883
+ if (this.debug) {
1884
+ data.times.JSRenderTime = Date.now() - (data.times.JSRenderTime || 0) - (data.times.IPCTime || 0);
1885
+ let total = 0;
1886
+ const count = data.times.bitmaps || data.images.length;
1887
+ delete data.times.bitmaps;
1888
+ for (const key in data.times) {
1889
+ total += data.times[key] || 0;
1890
+ }
1891
+ console.log(`[${this._rendererType.toUpperCase()}] Bitmaps: ` + count + ' Total: ' + (total | 0) + 'ms', data.times);
1892
+ }
1893
+ }
1894
+ _ready() {
1895
+ this._workerReady = true;
1896
+ this._init();
1897
+ if (this._video) {
1898
+ const currentTime = this._video.currentTime;
1899
+ const isPaused = this._isVideoPausedForWorker();
1900
+ const bufferExactFrames = isPaused && this._onDemandRender && !!this._frameTimeline?.length && this.framePrefetch > 0;
1901
+ this._frameBufferReadyEvent = bufferExactFrames ? 'ready' : null;
1902
+ this.setCurrentTime(isPaused, currentTime + this.timeOffset, this._videoPlaybackRateForWorker());
1903
+ if (!this._onDemandRender) {
1904
+ this.dispatchEvent(new CustomEvent('ready'));
1905
+ return;
1906
+ }
1907
+ const latestPending = this._pendingDemandTimes[this._pendingDemandTimes.length - 1];
1908
+ const canUsePendingDemand = !isPaused && latestPending && Math.abs(latestPending.mediaTime - currentTime) <= 0.25;
1909
+ const pending = canUsePendingDemand
1910
+ ? latestPending
1911
+ : {
1912
+ mediaTime: currentTime,
1913
+ width: this._video.videoWidth,
1914
+ height: this._video.videoHeight,
1915
+ expectedDisplayTime: isPaused ? undefined : performance.now()
1916
+ };
1917
+ this._pendingDemandTimes.length = 0;
1918
+ this.busy = true;
1919
+ this._demandRender(pending);
1920
+ if (bufferExactFrames)
1921
+ return;
1922
+ }
1923
+ this.dispatchEvent(new CustomEvent('ready'));
1924
+ }
1925
+ _partial_ready() {
1926
+ this.dispatchEvent(new CustomEvent('partial_ready'));
1927
+ }
1928
+ _trackReady() {
1929
+ const bufferExactFrames = this._workerReady &&
1930
+ this._isVideoPausedForWorker() &&
1931
+ this._onDemandRender &&
1932
+ !!this._frameTimeline?.length &&
1933
+ this.framePrefetch > 0;
1934
+ this._frameBufferReadyEvent = bufferExactFrames ? 'trackReady' : null;
1935
+ this._syncVideoClock();
1936
+ if (bufferExactFrames) {
1937
+ this._primePreparedFrames(this._currentExactFrameMediaTime());
1938
+ this._dispatchNextPreparation();
1939
+ return;
1940
+ }
1941
+ this.dispatchEvent(new CustomEvent('trackReady'));
1942
+ }
1943
+ async sendMessage(target, data = {}, transferable) {
1944
+ if (this._workerReady) {
1945
+ this._postWorkerMessage(target, data, transferable);
1946
+ return;
1947
+ }
1948
+ await this._loaded;
1949
+ // Drop stale pre-ready video/canvas updates; ready re-syncs current state.
1950
+ if ((target === 'video' || target === 'canvas') && this._video)
1951
+ return;
1952
+ this._postWorkerMessage(target, data, transferable);
1953
+ }
1954
+ _postWorkerMessage(target, data = {}, transferable) {
1955
+ if (transferable) {
1956
+ this._worker.postMessage({ target, transferable, ...data }, [...transferable]);
1957
+ }
1958
+ else {
1959
+ this._worker.postMessage({ target, ...data });
1960
+ }
1961
+ }
1962
+ _fetchFromWorker(workerOptions) {
1963
+ return new Promise((resolve, reject) => {
1964
+ let cleanup = () => { };
1965
+ try {
1966
+ if (this._destroyed)
1967
+ throw new Error('Renderer was destroyed before the worker request started');
1968
+ const { timeoutMs = 5000, ...workerMessage } = workerOptions;
1969
+ const target = workerMessage.target;
1970
+ const timeout = timeoutMs === null
1971
+ ? null
1972
+ : setTimeout(() => {
1973
+ cleanup();
1974
+ reject(new Error('Error: Timeout while trying to fetch ' + target));
1975
+ }, timeoutMs);
1976
+ const handleMessage = (event) => {
1977
+ if (event.data.target === target &&
1978
+ (workerMessage.requestId === undefined || event.data.requestId === workerMessage.requestId)) {
1979
+ cleanup();
1980
+ resolve(event.data);
1981
+ }
1982
+ };
1983
+ const handleError = (event) => {
1984
+ cleanup();
1985
+ reject(event instanceof Error ? event : event.error || new Error('Worker error'));
1986
+ };
1987
+ const handleDestroyed = (error) => {
1988
+ cleanup();
1989
+ reject(error);
1990
+ };
1991
+ cleanup = () => {
1992
+ this._worker.removeEventListener('message', handleMessage);
1993
+ this._worker.removeEventListener('error', handleError);
1994
+ this._pendingWorkerRejectors.delete(handleDestroyed);
1995
+ if (timeout !== null)
1996
+ clearTimeout(timeout);
1997
+ };
1998
+ this._worker.addEventListener('message', handleMessage);
1999
+ this._worker.addEventListener('error', handleError);
2000
+ this._pendingWorkerRejectors.add(handleDestroyed);
2001
+ this._worker.postMessage(workerMessage);
2002
+ }
2003
+ catch (error) {
2004
+ cleanup();
2005
+ reject(error);
2006
+ }
2007
+ });
2008
+ }
2009
+ _console(data) {
2010
+ ;
2011
+ console[data.command].apply(console, JSON.parse(data.content));
2012
+ }
2013
+ _onmessage(event) {
2014
+ const target = event.data.target;
2015
+ if (target === 'error') {
2016
+ this._error(event.data.error || 'Unknown worker error');
2017
+ return;
2018
+ }
2019
+ const handler = this['_' + target];
2020
+ if (handler) {
2021
+ handler.call(this, event.data);
2022
+ }
2023
+ }
2024
+ _error(err) {
2025
+ const error = err instanceof Error
2026
+ ? err
2027
+ : err instanceof ErrorEvent
2028
+ ? err.error || new Error(err.message)
2029
+ : new Error(String(err));
2030
+ const event = err instanceof Event ? new ErrorEvent(err.type, err) : new ErrorEvent('error', { error });
2031
+ this.dispatchEvent(event);
2032
+ console.error(error);
2033
+ return error;
2034
+ }
2035
+ _removeListeners() {
2036
+ this._cancelRVFC();
2037
+ if (this._video) {
2038
+ if (this._ro)
2039
+ this._ro.unobserve(this._video);
2040
+ if (this._ctx)
2041
+ this._ctx.filter = 'none';
2042
+ this._video.removeEventListener('timeupdate', this._boundTimeUpdate);
2043
+ this._video.removeEventListener('progress', this._boundTimeUpdate);
2044
+ this._video.removeEventListener('play', this._boundTimeUpdate);
2045
+ this._video.removeEventListener('pause', this._boundTimeUpdate);
2046
+ this._video.removeEventListener('ended', this._boundTimeUpdate);
2047
+ this._video.removeEventListener('waiting', this._boundTimeUpdate);
2048
+ this._video.removeEventListener('stalled', this._boundTimeUpdate);
2049
+ this._video.removeEventListener('seeking', this._boundTimeUpdate);
2050
+ this._video.removeEventListener('seeked', this._boundTimeUpdate);
2051
+ this._video.removeEventListener('playing', this._boundTimeUpdate);
2052
+ this._video.removeEventListener('ratechange', this._boundSetRate);
2053
+ this._video.removeEventListener('resize', this._boundResize);
2054
+ this._video.removeEventListener('loadedmetadata', this._boundUpdateColorSpace);
2055
+ }
2056
+ }
2057
+ destroy(err) {
2058
+ const error = err ? this._error(err) : undefined;
2059
+ if (this._destroyed)
2060
+ return error;
2061
+ if (this._refreshRafHandle != null && typeof cancelAnimationFrame === 'function') {
2062
+ cancelAnimationFrame(this._refreshRafHandle);
2063
+ this._refreshRafHandle = null;
2064
+ }
2065
+ this._clearPreparedFrames(false);
2066
+ if (this._video && this._canvasParent) {
2067
+ this._video.parentNode?.removeChild(this._canvasParent);
2068
+ }
2069
+ if (this._gpuRenderer) {
2070
+ this._gpuRenderer.destroy();
2071
+ this._gpuRenderer = null;
2072
+ this._rendererType = 'canvas2d';
2073
+ }
2074
+ this._destroyed = true;
2075
+ this._resolveDestroyed?.();
2076
+ const destroyedError = new Error('Renderer was destroyed before the worker request completed');
2077
+ for (const rejectPending of [...this._pendingWorkerRejectors])
2078
+ rejectPending(destroyedError);
2079
+ this._removeListeners();
2080
+ if (this._workerReady)
2081
+ this._postWorkerMessage('destroy');
2082
+ this._worker?.terminate();
2083
+ return error;
2084
+ }
2085
+ }
2086
+ //# sourceMappingURL=akarisub.js.map