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