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.
- package/README.md +3 -3
- package/dist/akarisub-worker.js +2 -2
- package/dist/akarisub-worker.wasm +0 -0
- package/dist/akarisub.umd.js +3 -3
- package/dist/index.js +3 -3
- package/dist/ts/index.d.ts +14 -0
- package/dist/ts/index.d.ts.map +1 -0
- package/dist/ts/index.js +17 -0
- package/dist/ts/index.js.map +1 -0
- package/dist/ts/ts/akarisub.d.ts +229 -0
- package/dist/ts/ts/akarisub.d.ts.map +1 -0
- package/dist/ts/ts/akarisub.js +1079 -0
- package/dist/ts/ts/akarisub.js.map +1 -0
- package/dist/ts/ts/types.d.ts +424 -0
- package/dist/ts/ts/types.d.ts.map +1 -0
- package/dist/ts/ts/types.js +5 -0
- package/dist/ts/ts/types.js.map +1 -0
- package/dist/ts/ts/utils.d.ts +78 -0
- package/dist/ts/ts/utils.d.ts.map +1 -0
- package/dist/ts/ts/utils.js +395 -0
- package/dist/ts/ts/utils.js.map +1 -0
- package/dist/ts/ts/webgl2-renderer.d.ts +51 -0
- package/dist/ts/ts/webgl2-renderer.d.ts.map +1 -0
- package/dist/ts/ts/webgl2-renderer.js +388 -0
- package/dist/ts/ts/webgl2-renderer.js.map +1 -0
- package/dist/ts/ts/webgpu-renderer.d.ts +64 -0
- package/dist/ts/ts/webgpu-renderer.d.ts.map +1 -0
- package/dist/ts/ts/webgpu-renderer.js +610 -0
- package/dist/ts/ts/webgpu-renderer.js.map +1 -0
- package/dist/ts/ts/worker.d.ts +6 -0
- package/dist/ts/ts/worker.d.ts.map +1 -0
- package/dist/ts/ts/worker.js +1695 -0
- package/dist/ts/ts/worker.js.map +1 -0
- package/dist/ts/wrapper.d.ts +8 -0
- package/dist/ts/wrapper.d.ts.map +1 -0
- package/dist/ts/wrapper.js +9 -0
- package/dist/ts/wrapper.js.map +1 -0
- package/package.json +7 -6
- package/src/ts/akarisub.ts +46 -4
- package/src/ts/types.ts +18 -4
- 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
|