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