akarisub 0.2.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +198 -167
  2. package/THIRD_PARTY_NOTICES.md +55 -163
  3. package/dist/COPYRIGHT +56 -164
  4. package/dist/index.d.ts +12 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +11 -147
  7. package/dist/index.js.map +1 -0
  8. package/dist/ts/{ts/akarisub.d.ts → akarisub.d.ts} +80 -99
  9. package/dist/ts/akarisub.d.ts.map +1 -0
  10. package/dist/ts/akarisub.js +2086 -0
  11. package/dist/ts/akarisub.js.map +1 -0
  12. package/dist/ts/timing.d.ts +63 -0
  13. package/dist/ts/timing.d.ts.map +1 -0
  14. package/dist/ts/timing.js +219 -0
  15. package/dist/ts/timing.js.map +1 -0
  16. package/dist/ts/{ts/types.d.ts → types.d.ts} +103 -78
  17. package/dist/ts/types.d.ts.map +1 -0
  18. package/dist/ts/types.js +2 -0
  19. package/dist/ts/{ts/types.js.map → types.js.map} +1 -1
  20. package/dist/ts/{ts/utils.d.ts → utils.d.ts} +0 -31
  21. package/dist/ts/utils.d.ts.map +1 -0
  22. package/dist/ts/{ts/utils.js → utils.js} +0 -56
  23. package/dist/ts/utils.js.map +1 -0
  24. package/dist/ts/wasm.d.ts +12 -0
  25. package/dist/ts/wasm.d.ts.map +1 -0
  26. package/dist/ts/wasm.js +46 -0
  27. package/dist/ts/wasm.js.map +1 -0
  28. package/dist/ts/{ts/webgl2-renderer.d.ts → webgl2-renderer.d.ts} +0 -12
  29. package/dist/ts/webgl2-renderer.d.ts.map +1 -0
  30. package/dist/ts/{ts/webgl2-renderer.js → webgl2-renderer.js} +0 -27
  31. package/dist/ts/webgl2-renderer.js.map +1 -0
  32. package/dist/ts/{ts/webgpu-renderer.d.ts → webgpu-renderer.d.ts} +17 -16
  33. package/dist/ts/webgpu-renderer.d.ts.map +1 -0
  34. package/dist/ts/{ts/webgpu-renderer.js → webgpu-renderer.js} +148 -71
  35. package/dist/ts/webgpu-renderer.js.map +1 -0
  36. package/dist/ts/worker.d.ts +2 -0
  37. package/dist/ts/{ts/worker.d.ts.map → worker.d.ts.map} +1 -1
  38. package/dist/ts/{ts/worker.js → worker.js} +1064 -264
  39. package/dist/ts/worker.js.map +1 -0
  40. package/dist/wrapper.d.ts +8 -0
  41. package/dist/wrapper.d.ts.map +1 -0
  42. package/dist/{ts/index.js → wrapper.js} +4 -14
  43. package/dist/wrapper.js.map +1 -0
  44. package/package.json +24 -22
  45. package/pkg/akarisub.js +53 -0
  46. package/{dist/akarisub-worker.wasm → pkg/akarisub.wasm} +0 -0
  47. package/src/wrapper.ts +46 -0
  48. package/dist/akarisub-worker.js +0 -39
  49. package/dist/akarisub.umd.js +0 -159
  50. package/dist/ts/index.d.ts +0 -14
  51. package/dist/ts/index.d.ts.map +0 -1
  52. package/dist/ts/index.js.map +0 -1
  53. package/dist/ts/ts/akarisub.d.ts.map +0 -1
  54. package/dist/ts/ts/akarisub.js +0 -1073
  55. package/dist/ts/ts/akarisub.js.map +0 -1
  56. package/dist/ts/ts/types.d.ts.map +0 -1
  57. package/dist/ts/ts/types.js +0 -5
  58. package/dist/ts/ts/utils.d.ts.map +0 -1
  59. package/dist/ts/ts/utils.js.map +0 -1
  60. package/dist/ts/ts/webgl2-renderer.d.ts.map +0 -1
  61. package/dist/ts/ts/webgl2-renderer.js.map +0 -1
  62. package/dist/ts/ts/webgpu-renderer.d.ts.map +0 -1
  63. package/dist/ts/ts/webgpu-renderer.js.map +0 -1
  64. package/dist/ts/ts/worker.d.ts +0 -6
  65. package/dist/ts/ts/worker.js.map +0 -1
  66. package/dist/ts/wrapper.d.ts +0 -8
  67. package/dist/ts/wrapper.d.ts.map +0 -1
  68. package/dist/ts/wrapper.js +0 -9
  69. package/dist/ts/wrapper.js.map +0 -1
  70. package/src/ts/akarisub.ts +0 -1272
  71. package/src/ts/types.ts +0 -397
  72. package/src/ts/utils.ts +0 -512
  73. package/src/ts/webgl2-renderer.ts +0 -426
  74. package/src/ts/webgpu-renderer.ts +0 -691
  75. package/src/ts/worker.ts +0 -2052
  76. /package/{dist → assets}/default.woff2 +0 -0
package/src/ts/worker.ts DELETED
@@ -1,2052 +0,0 @@
1
- /**
2
- * AkariSub Worker - TypeScript implementation.
3
- * Runs in a Web Worker to offload subtitle rendering from the main thread.
4
- */
5
-
6
- /// <reference lib="webworker" />
7
-
8
- // @ts-ignore - WASM module is aliased during build
9
- import WASM from 'wasm'
10
-
11
- import type {
12
- ASSEvent,
13
- ASSStyle,
14
- AkariSubModule,
15
- EncryptedSubtitleContent,
16
- SubtitleColorSpace,
17
- WorkerInboundMessage
18
- } from './types'
19
- import { parseAss, dropBlur, fixPlayRes, libassYCbCrMap } from './utils'
20
-
21
- // =============================================================================
22
- // Worker State
23
- // =============================================================================
24
-
25
- interface WorkerMetrics {
26
- framesRendered: number
27
- framesDropped: number
28
- totalRenderTime: number
29
- maxRenderTime: number
30
- minRenderTime: number
31
- lastRenderTime: number
32
- renderStartTime: number
33
- pendingRenders: number
34
- totalEvents: number
35
- currentEventIndex: number
36
- cacheHits: number
37
- cacheMisses: number
38
- }
39
-
40
- declare const self: DedicatedWorkerGlobalScope & {
41
- width: number
42
- height: number
43
- HEAPU8: Uint8Array
44
- HEAPU8C: Uint8ClampedArray
45
- wasmMemory: WebAssembly.Memory
46
- [key: string]: any
47
- }
48
-
49
- let lastCurrentTime = 0
50
- let rate = 1
51
- let rafId: number | null = null
52
- let nextIsRaf = false
53
- const nowMs = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now())
54
- let lastCurrentTimeReceivedAt = nowMs()
55
- let targetFps = 24
56
- let onDemandRenderMode = false
57
- let useLocalFonts = false
58
- let blendMode: 'js' | 'wasm' = 'wasm'
59
- let availableFonts: Record<string, string | Uint8Array> = {}
60
- const fontMap_: Record<string, boolean> = {}
61
- let attachedFontId = 0 // For attached/preloaded fonts (higher priority)
62
- let fallbackFontId = 0 // For fallback fonts (lower priority)
63
- const pendingFallbackFonts: { data: Uint8Array; name: string }[] = []
64
- let debug = false
65
- let clampPos = false
66
- let renderInFlight = false
67
- const MAX_QUEUED_RENDERS = 3
68
- const queuedRenders: Array<{ time: number; force: 0 | 1 }> = []
69
-
70
- self.width = 0
71
- self.height = 0
72
-
73
- // Performance metrics
74
- const metrics: WorkerMetrics = {
75
- framesRendered: 0,
76
- framesDropped: 0,
77
- totalRenderTime: 0,
78
- maxRenderTime: 0,
79
- minRenderTime: Infinity,
80
- lastRenderTime: 0,
81
- renderStartTime: 0,
82
- pendingRenders: 0,
83
- totalEvents: 0,
84
- currentEventIndex: 0,
85
- cacheHits: 0,
86
- cacheMisses: 0
87
- }
88
-
89
- const resetMetrics = (): void => {
90
- metrics.framesRendered = 0
91
- metrics.framesDropped = 0
92
- metrics.totalRenderTime = 0
93
- metrics.maxRenderTime = 0
94
- metrics.minRenderTime = Infinity
95
- metrics.lastRenderTime = 0
96
- metrics.cacheHits = 0
97
- metrics.cacheMisses = 0
98
- }
99
-
100
- let asyncRender = false
101
- let asyncRenderOptions = true
102
- let offCanvas: OffscreenCanvas | null = null
103
- let offCanvasCtx: OffscreenCanvasRenderingContext2D | null = null
104
- let offscreenRender: boolean | 'hybrid' = false
105
- let bufferCanvas: OffscreenCanvas | null = null
106
- let bufferCtx: OffscreenCanvasRenderingContext2D | null = null
107
- let akariSubHandle = 0
108
- let subtitleColorSpace: SubtitleColorSpace = null
109
- let dropAllBlur = false
110
- let fullTrackWarmupEnabled = false
111
- let hasBitmapBug = false
112
- let _Module: AkariSubModule | null = null
113
- let forceNextDemandRender = false
114
-
115
- const TEXT_ENCODER = new TextEncoder()
116
- const TEXT_DECODER = new TextDecoder()
117
-
118
- interface AkariSubApi {
119
- create: (width: number, height: number, fallbackFontPtr: number, debug: number) => number
120
- destroy: (handle: number) => void
121
- setDropAnimations: (handle: number, value: number) => void
122
- createTrackMem: (handle: number, contentPtr: number) => void
123
- removeTrack: (handle: number) => void
124
- resizeCanvas: (handle: number, width: number, height: number, videoWidth: number, videoHeight: number) => void
125
- addFont: (handle: number, namePtr: number, dataPtr: number, dataSize: number) => void
126
- reloadFonts: (handle: number) => void
127
- setDefaultFont: (handle: number, fontPtr: number) => void
128
- setFallbackFonts: (handle: number, fontsPtr: number) => void
129
- setMemoryLimits: (handle: number, glyphLimit: number, memoryLimit: number) => void
130
- getEventCount: (handle: number) => number
131
- allocEvent: (handle: number) => number
132
- removeEvent: (handle: number, index: number) => void
133
- getStyleCount: (handle: number) => number
134
- allocStyle: (handle: number) => number
135
- removeStyle: (handle: number, index: number) => void
136
- styleOverrideIndex: (handle: number, index: number) => void
137
- disableStyleOverride: (handle: number) => void
138
- getTrackColorSpace: (handle: number) => number
139
- eventGetInt: (handle: number, index: number, field: number) => number
140
- eventSetInt: (handle: number, index: number, field: number, value: number) => void
141
- eventGetStr: (handle: number, index: number, field: number) => number
142
- eventSetStr: (handle: number, index: number, field: number, valuePtr: number) => void
143
- styleGetNum: (handle: number, index: number, field: number) => number
144
- styleSetNum: (handle: number, index: number, field: number, value: number) => void
145
- styleGetStr: (handle: number, index: number, field: number) => number
146
- styleSetStr: (handle: number, index: number, field: number, valuePtr: number) => void
147
- getEventTimeRange: (handle: number, outPtr: number) => number
148
- getEmptyWindow: (handle: number, tm: number, outPtr: number) => number
149
- renderBlendCollect: (handle: number, time: number, force: number, outPtr: number, maxItems: number) => number
150
- renderImageCollect: (handle: number, time: number, force: number, outPtr: number, maxItems: number) => number
151
- }
152
-
153
- let akariSubApi: AkariSubApi | null = null
154
-
155
- // Pre-allocated object pool for render results
156
- const MAX_POOLED_IMAGES = 128
157
- const RENDER_COLLECT_MAX_IMAGES = Math.max(MAX_POOLED_IMAGES, 4096)
158
- const PREWARM_MAX_IMAGES = RENDER_COLLECT_MAX_IMAGES
159
- const WARMUP_AHEAD_SECONDS = 30
160
- const WARMUP_STEP_SECONDS = 0.5
161
- const WARMUP_TICK_MS = 40
162
- const ENABLE_RUNTIME_WARMUP = false
163
- const FULL_WARMUP_CAP_SECONDS = 30
164
- const FULL_WARMUP_STEP_SECONDS = 1
165
- const FULL_WARMUP_YIELD_EVERY = 24
166
- const ASS_TIME_SCALE = 1000
167
- const imagePool: RenderResultItem[] = []
168
- let poolInitialized = false
169
- // Batch render-collect buffer: 3 header ints (changed, count, time) + 5 ints per image (x, y, w, h, image_ptr)
170
- const RRC_HEADER_INTS = 3
171
- const RRC_IMG_STRIDE = 5
172
- // Pre-allocated buffer for batch render-collect calls
173
- let rrcBufPtr = 0
174
- let rrcBufCapacity = 0
175
- // Cached views over the render-collect buffer; refreshed when the wasm memory
176
- // grows (buffer identity changes) or the buffer is reallocated/resized.
177
- let rrcHeaderView: Int32Array | null = null
178
- let rrcMetaView: Int32Array | null = null
179
- let rrcViewsBuffer: ArrayBufferLike | null = null
180
- let rrcViewsPtr = 0
181
- let rrcViewsCapacity = 0
182
- const frameImages: RenderResultItem[] = []
183
- const frameArrayBuffers: ArrayBuffer[] = []
184
- const frameBitmapPromises: Promise<ImageBitmap>[] = []
185
- let warmupTimer: ReturnType<typeof setTimeout> | null = null
186
- let warmupCursorTime = 0
187
- let warmupEndTime = 0
188
- let warmupEnabled = false
189
- let firstTrackEventStartTime: number | null = null
190
- let fullTrackWarmupPromise: Promise<void> | null = null
191
- let protectedTrackContent = false
192
-
193
- const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
194
-
195
- interface RenderResultItem {
196
- w: number
197
- h: number
198
- x: number
199
- y: number
200
- image: number | ImageBitmap | Uint8ClampedArray
201
- }
202
-
203
- const initPool = (): void => {
204
- if (poolInitialized) return
205
- for (let i = 0; i < MAX_POOLED_IMAGES; i++) {
206
- imagePool[i] = { w: 0, h: 0, x: 0, y: 0, image: 0 }
207
- }
208
- poolInitialized = true
209
- }
210
-
211
- const getPooledItem = (index: number): RenderResultItem => {
212
- let item = imagePool[index]
213
- if (!item) {
214
- // Grow the pool on demand so frames with many images reuse items too
215
- item = { w: 0, h: 0, x: 0, y: 0, image: 0 }
216
- imagePool[index] = item
217
- }
218
- return item
219
- }
220
-
221
- /**
222
- * Ensure the batch render-collect buffer is large enough.
223
- * Layout: [changed, count, time, (x, y, w, h, image_ptr) * N]
224
- * = 3 + 5*N ints
225
- */
226
- const ensureRenderCollectBuffer = (maxImages: number): void => {
227
- if (!_Module || maxImages <= 0) return
228
- const totalInts = RRC_HEADER_INTS + RRC_IMG_STRIDE * maxImages
229
- if (rrcBufCapacity >= totalInts && rrcBufPtr) return
230
-
231
- const nextCapacity = Math.max(totalInts, (rrcBufCapacity || 64) * 2)
232
- const nextSizeBytes = nextCapacity * Int32Array.BYTES_PER_ELEMENT
233
-
234
- if (rrcBufPtr) {
235
- _Module._free(rrcBufPtr)
236
- rrcBufPtr = 0
237
- rrcBufCapacity = 0
238
- }
239
-
240
- rrcBufPtr = _Module._malloc(nextSizeBytes)
241
- if (!rrcBufPtr) {
242
- rrcBufCapacity = 0
243
- throw new Error('Failed to allocate render-collect buffer')
244
- }
245
-
246
- rrcBufCapacity = nextCapacity
247
- }
248
-
249
- const refreshRrcViews = (): void => {
250
- const buffer = self.wasmMemory.buffer
251
- if (rrcHeaderView && rrcViewsBuffer === buffer && rrcViewsPtr === rrcBufPtr && rrcViewsCapacity === rrcBufCapacity) {
252
- return
253
- }
254
- rrcHeaderView = new Int32Array(buffer, rrcBufPtr, RRC_HEADER_INTS)
255
- rrcMetaView = new Int32Array(
256
- buffer,
257
- rrcBufPtr + RRC_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT,
258
- rrcBufCapacity - RRC_HEADER_INTS
259
- )
260
- rrcViewsBuffer = buffer
261
- rrcViewsPtr = rrcBufPtr
262
- rrcViewsCapacity = rrcBufCapacity
263
- }
264
-
265
- const prewarmRenderer = (time: number): void => {
266
- if (!akariSubHandle) return
267
-
268
- const api = requireApi()
269
- const handle = requireHandle()
270
- ensureRenderCollectBuffer(PREWARM_MAX_IMAGES)
271
-
272
- if (blendMode === 'wasm') {
273
- api.renderBlendCollect(handle, time, 0, rrcBufPtr, rrcBufCapacity)
274
- } else {
275
- api.renderImageCollect(handle, time, 0, rrcBufPtr, rrcBufCapacity)
276
- }
277
- }
278
-
279
- const syncTotalEventsMetric = (): void => {
280
- metrics.totalEvents = akariSubHandle ? requireApi().getEventCount(akariSubHandle) : 0
281
- }
282
-
283
- const getTrackEventTimeRange = (): { start: number; end: number } | null => {
284
- if (!akariSubHandle || !_Module) return null
285
-
286
- const api = requireApi()
287
- const handle = requireHandle()
288
- const outPtr = _Module._malloc(2 * Int32Array.BYTES_PER_ELEMENT)
289
- if (!outPtr) return null
290
-
291
- try {
292
- if (!api.getEventTimeRange(handle, outPtr)) return null
293
-
294
- const view = new Int32Array(self.wasmMemory.buffer, outPtr, 2)
295
- const start = Math.max(0, view[0] / ASS_TIME_SCALE)
296
- const end = Math.max(start, view[1] / ASS_TIME_SCALE)
297
- return { start, end }
298
- } finally {
299
- _Module._free(outPtr)
300
- }
301
- }
302
-
303
- const getFirstEventStartTime = (): number | null => {
304
- return getTrackEventTimeRange()?.start ?? null
305
- }
306
-
307
- let emptyWindowFrom = -1
308
- let emptyWindowUntil = -1
309
-
310
- const invalidateEmptyWindow = (): void => {
311
- emptyWindowFrom = -1
312
- emptyWindowUntil = -1
313
- }
314
-
315
- const computeEmptyWindow = (time: number): void => {
316
- if (!akariSubHandle || !_Module) return
317
-
318
- const outPtr = _Module._malloc(Int32Array.BYTES_PER_ELEMENT)
319
- if (!outPtr) return
320
-
321
- try {
322
- if (!requireApi().getEmptyWindow(requireHandle(), time, outPtr)) return
323
-
324
- const nextStart = new Int32Array(self.wasmMemory.buffer, outPtr, 1)[0]
325
- emptyWindowFrom = time
326
- emptyWindowUntil = nextStart < 0 ? Number.POSITIVE_INFINITY : nextStart / ASS_TIME_SCALE
327
- } finally {
328
- _Module._free(outPtr)
329
- }
330
- }
331
-
332
- const prewarmEntireTrack = async (): Promise<void> => {
333
- if (!akariSubHandle) return
334
-
335
- const range = getTrackEventTimeRange()
336
- if (!range) return
337
-
338
- const cappedEnd = Math.min(range.end, range.start + FULL_WARMUP_CAP_SECONDS)
339
-
340
- let ticks = 0
341
-
342
- for (let time = range.start; time <= cappedEnd; time += FULL_WARMUP_STEP_SECONDS) {
343
- if (!akariSubHandle) return
344
-
345
- if (onDemandRenderMode && (renderInFlight || queuedRenders.length > 0 || metrics.pendingRenders > 0)) {
346
- await sleep(0)
347
- continue
348
- }
349
-
350
- prewarmRenderer(time)
351
- ticks++
352
-
353
- if (onDemandRenderMode || ticks % FULL_WARMUP_YIELD_EVERY === 0) {
354
- await sleep(0)
355
- }
356
- }
357
-
358
- prewarmRenderer(cappedEnd)
359
- }
360
-
361
- const getWarmupAnchorTime = (fallbackTime: number): number => {
362
- if (firstTrackEventStartTime == null) return fallbackTime
363
- if (fallbackTime < firstTrackEventStartTime) return firstTrackEventStartTime
364
- return fallbackTime
365
- }
366
-
367
- const stopWarmup = (): void => {
368
- warmupEnabled = false
369
- if (warmupTimer) {
370
- clearTimeout(warmupTimer)
371
- warmupTimer = null
372
- }
373
- }
374
-
375
- const scheduleFullTrackWarmup = (): void => {
376
- if (!fullTrackWarmupEnabled || fullTrackWarmupPromise || !akariSubHandle) return
377
-
378
- fullTrackWarmupPromise = (async () => {
379
- await sleep(0)
380
-
381
- try {
382
- await prewarmEntireTrack()
383
- } catch (e) {
384
- if (debug) console.warn('[AkariSub] Full track warmup failed, continuing:', e)
385
- }
386
-
387
- try {
388
- if (akariSubHandle) {
389
- prewarmRenderer(getCurrentTime())
390
- }
391
- } catch (e) {
392
- if (debug) console.warn('[AkariSub] Post-warmup re-prime failed, continuing:', e)
393
- }
394
- })().finally(() => {
395
- fullTrackWarmupPromise = null
396
- })
397
- }
398
-
399
- const scheduleWarmupTick = (): void => {
400
- if (!warmupEnabled || warmupTimer) return
401
- warmupTimer = setTimeout(runWarmupTick, WARMUP_TICK_MS)
402
- }
403
-
404
- const startWarmupWindow = (fromTime: number): void => {
405
- if (!ENABLE_RUNTIME_WARMUP) return
406
- if (!akariSubHandle || !Number.isFinite(fromTime)) return
407
- warmupCursorTime = fromTime
408
- warmupEndTime = fromTime + WARMUP_AHEAD_SECONDS
409
- warmupEnabled = true
410
- scheduleWarmupTick()
411
- }
412
-
413
- const runWarmupTick = (): void => {
414
- warmupTimer = null
415
-
416
- if (!warmupEnabled || !akariSubHandle) {
417
- warmupEnabled = false
418
- return
419
- }
420
-
421
- if (warmupCursorTime >= warmupEndTime) {
422
- warmupEnabled = false
423
- return
424
- }
425
-
426
- if (renderInFlight || queuedRenders.length > 0 || metrics.pendingRenders > 0) {
427
- scheduleWarmupTick()
428
- return
429
- }
430
-
431
- try {
432
- const now = getCurrentTime()
433
- if (warmupCursorTime < now) {
434
- warmupCursorTime = now
435
- }
436
-
437
- prewarmRenderer(warmupCursorTime)
438
- warmupCursorTime += WARMUP_STEP_SECONDS
439
- } catch (e) {
440
- if (debug) console.warn('[AkariSub] Warmup tick failed, continuing:', e)
441
- warmupCursorTime += WARMUP_STEP_SECONDS
442
- }
443
-
444
- scheduleWarmupTick()
445
- }
446
-
447
- const EVENT_INT_FIELDS: Record<string, number> = {
448
- Start: 0,
449
- Duration: 1,
450
- ReadOrder: 2,
451
- Layer: 3,
452
- Style: 4,
453
- MarginL: 5,
454
- MarginR: 6,
455
- MarginV: 7
456
- }
457
-
458
- const EVENT_STR_FIELDS: Record<string, number> = {
459
- Name: 0,
460
- Effect: 1,
461
- Text: 2
462
- }
463
-
464
- const STYLE_NUM_FIELDS: Record<string, number> = {
465
- FontSize: 0,
466
- PrimaryColour: 1,
467
- SecondaryColour: 2,
468
- OutlineColour: 3,
469
- BackColour: 4,
470
- Bold: 5,
471
- Italic: 6,
472
- Underline: 7,
473
- StrikeOut: 8,
474
- ScaleX: 9,
475
- ScaleY: 10,
476
- Spacing: 11,
477
- Angle: 12,
478
- BorderStyle: 13,
479
- Outline: 14,
480
- Shadow: 15,
481
- Alignment: 16,
482
- MarginL: 17,
483
- MarginR: 18,
484
- MarginV: 19,
485
- Encoding: 20,
486
- treat_fontname_as_pattern: 21,
487
- Blur: 22,
488
- Justify: 23
489
- }
490
-
491
- const STYLE_STR_FIELDS: Record<string, number> = {
492
- Name: 0,
493
- FontName: 1
494
- }
495
-
496
- const encodeString = (input: string): Uint8Array => {
497
- return TEXT_ENCODER.encode(input)
498
- }
499
-
500
- const allocString = (input: string): number => {
501
- if (!_Module) return 0
502
- const bytes = encodeString(input)
503
- const ptr = _Module._malloc(bytes.length + 1)
504
- if (!ptr) return 0
505
- self.HEAPU8.set(bytes, ptr)
506
- self.HEAPU8[ptr + bytes.length] = 0
507
- return ptr
508
- }
509
-
510
- const readCString = (ptr: number): string => {
511
- if (!ptr) return ''
512
- let end = ptr
513
- const heap = self.HEAPU8
514
- while (heap[end] !== 0) end++
515
- return TEXT_DECODER.decode(heap.subarray(ptr, end))
516
- }
517
-
518
- const withCString = <T>(input: string, callback: (ptr: number) => T): T => {
519
- const ptr = allocString(input)
520
- try {
521
- return callback(ptr)
522
- } finally {
523
- if (ptr && _Module) _Module._free(ptr)
524
- }
525
- }
526
-
527
- const toUint8Array = (content: Uint8Array | ArrayBuffer): Uint8Array => {
528
- return content instanceof Uint8Array ? content : new Uint8Array(content)
529
- }
530
-
531
- const isBinaryContent = (content: string | Uint8Array | ArrayBuffer): content is Uint8Array | ArrayBuffer => {
532
- return content instanceof Uint8Array || content instanceof ArrayBuffer
533
- }
534
-
535
- const withCBytes = <T>(input: Uint8Array, callback: (ptr: number) => T): T => {
536
- if (!_Module) throw new Error('AkariSub module is not initialized')
537
-
538
- const ptr = _Module._malloc(input.length + 1)
539
- if (!ptr) throw new Error('Failed to allocate subtitle content')
540
-
541
- try {
542
- self.HEAPU8.set(input, ptr)
543
- self.HEAPU8[ptr + input.length] = 0
544
- return callback(ptr)
545
- } finally {
546
- self.HEAPU8.fill(0, ptr, ptr + input.length + 1)
547
- _Module._free(ptr)
548
- }
549
- }
550
-
551
- const decryptV2Payload = async (encrypted: ArrayBuffer, contentKey: CryptoKey): Promise<Uint8Array> => {
552
- const data = new Uint8Array(encrypted)
553
- const keyIdSize = 8
554
- const nonceSize = 12
555
- const headerSize = 1 + keyIdSize + nonceSize
556
-
557
- if (data.length < headerSize + 16) {
558
- throw new Error('Ciphertext too short for v2 subtitle payload')
559
- }
560
-
561
- if (data[0] !== 2) {
562
- throw new Error('Unsupported encrypted subtitle protocol version')
563
- }
564
-
565
- const header = data.subarray(0, 1 + keyIdSize)
566
- const nonce = data.subarray(1 + keyIdSize, headerSize)
567
- const ciphertext = data.subarray(headerSize)
568
- const decrypted = await crypto.subtle.decrypt(
569
- {
570
- name: 'AES-GCM',
571
- iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength),
572
- additionalData: header.buffer.slice(header.byteOffset, header.byteOffset + header.byteLength),
573
- tagLength: 128
574
- },
575
- contentKey,
576
- ciphertext.buffer.slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength)
577
- )
578
-
579
- return new Uint8Array(decrypted)
580
- }
581
-
582
- const decryptSubtitleContent = async (content: EncryptedSubtitleContent): Promise<Uint8Array> => {
583
- if (content.encrypted) {
584
- return decryptV2Payload(content.encrypted, content.contentKey)
585
- }
586
-
587
- const chunks = content.encryptedChunks || []
588
- if (chunks.length === 0) {
589
- throw new Error('Encrypted subtitle content is empty')
590
- }
591
-
592
- const decryptedChunks = await Promise.all(chunks.map((chunk) => decryptV2Payload(chunk, content.contentKey)))
593
- const totalLength = decryptedChunks.reduce((sum, chunk) => sum + chunk.length, 0)
594
- const result = new Uint8Array(totalLength)
595
- let offset = 0
596
-
597
- for (const chunk of decryptedChunks) {
598
- result.set(chunk, offset)
599
- chunk.fill(0)
600
- offset += chunk.length
601
- }
602
-
603
- return result
604
- }
605
-
606
- const createTrackFromBytes = (content: Uint8Array): void => {
607
- const api = requireApi()
608
- const handle = requireHandle()
609
- withCBytes(content, (contentPtr) => {
610
- api.createTrackMem(handle, contentPtr)
611
- })
612
- }
613
-
614
- const createTrackFromString = (content: string): void => {
615
- const api = requireApi()
616
- const handle = requireHandle()
617
- withCString(content, (contentPtr) => {
618
- api.createTrackMem(handle, contentPtr)
619
- })
620
- }
621
-
622
- const requireApi = (): AkariSubApi => {
623
- if (!akariSubApi) throw new Error('AkariSub API is not initialized')
624
- return akariSubApi
625
- }
626
-
627
- const requireHandle = (): number => {
628
- if (!akariSubHandle) throw new Error('AkariSub instance is not initialized')
629
- return akariSubHandle
630
- }
631
-
632
- // =============================================================================
633
- // Font Management
634
- // =============================================================================
635
-
636
- // Fonts added via addFont are explicitly requested, so they should be attached (high priority)
637
- self.addFont = ({ font }: { font: string | Uint8Array }) => asyncWrite(font, false)
638
-
639
- const findAvailableFonts = (font: string): void => {
640
- font = font.trim().toLowerCase()
641
- if (font.startsWith('@')) font = font.substring(1)
642
- if (fontMap_[font]) return
643
-
644
- fontMap_[font] = true
645
-
646
- if (!availableFonts[font]) {
647
- if (useLocalFonts) postMessage({ target: 'getLocalFont', font })
648
- } else {
649
- asyncWrite(availableFonts[font])
650
- }
651
- }
652
-
653
- const asyncWrite = (font: string | Uint8Array, isFallback: boolean = true): void => {
654
- if (typeof font === 'string') {
655
- readAsync(
656
- font,
657
- (fontData) => {
658
- writeFontToFS(new Uint8Array(fontData), isFallback)
659
- },
660
- console.error
661
- )
662
- } else {
663
- writeFontToFS(font, isFallback)
664
- }
665
- }
666
-
667
- // Synchronous font loading for critical fonts (fallback fonts)
668
- const syncWrite = (font: string | Uint8Array, isFallback: boolean = true): void => {
669
- if (typeof font === 'string') {
670
- const fontData = read_(font, true) as ArrayBuffer
671
- if (fontData) {
672
- writeFontToFSImmediate(new Uint8Array(fontData), isFallback)
673
- }
674
- } else {
675
- writeFontToFSImmediate(font, isFallback)
676
- }
677
- }
678
-
679
- // Debounced font reload
680
- let pendingFontReload: ReturnType<typeof setTimeout> | null = null
681
- const scheduleReloadFonts = (): void => {
682
- if (pendingFontReload) return
683
- pendingFontReload = setTimeout(() => {
684
- pendingFontReload = null
685
- if (akariSubHandle) {
686
- const api = requireApi()
687
- api.reloadFonts(akariSubHandle)
688
- }
689
- }, 16)
690
- }
691
-
692
- /**
693
- * Add a font as an embedded font via ass_add_font.
694
- * Embedded fonts have higher priority than fontconfig fonts in libass.
695
- */
696
- const addFontAsEmbedded = (uint8: Uint8Array, name: string): void => {
697
- if (!_Module || !akariSubHandle) {
698
- if (debug) console.warn('[AkariSub] Cannot add embedded font, module or AkariSub not ready:', name)
699
- return
700
- }
701
-
702
- try {
703
- const api = requireApi()
704
- // Allocate memory in WASM heap and copy font data
705
- const ptr = _Module._malloc(uint8.length)
706
- if (!ptr) {
707
- console.warn('[AkariSub] Failed to allocate memory for embedded font:', name)
708
- return
709
- }
710
-
711
- // Copy font data to WASM heap
712
- self.HEAPU8.set(uint8, ptr)
713
-
714
- withCString(name, (namePtr) => {
715
- api.addFont(akariSubHandle, namePtr, ptr, uint8.length)
716
- })
717
-
718
- if (debug) console.log('[AkariSub] Added embedded font:', name, 'size:', uint8.length)
719
- } catch (e) {
720
- console.warn('[AkariSub] Failed to add embedded font:', name, e)
721
- }
722
- }
723
-
724
- /**
725
- * Write a font to the virtual filesystem so fontconfig can index it.
726
- * Fonts are written to separate directories based on priority:
727
- * - /fonts/attached: For attached/preloaded fonts (highest priority)
728
- * - /fonts/fallback: For fallback fonts
729
- */
730
- const writeFontToFS = (uint8: Uint8Array, isFallback: boolean = true): void => {
731
- const fontDir = isFallback ? '/fonts/fallback' : '/fonts/attached'
732
- const fontFileName = isFallback ? 'fallback-' + fallbackFontId++ : 'attached-' + attachedFontId++
733
-
734
- if (_Module) {
735
- try {
736
- _Module.FS_createDataFile(fontDir, fontFileName, uint8, true, true, true)
737
- } catch (e) {
738
- console.warn('Failed to write font to filesystem:', fontDir + '/' + fontFileName, e)
739
- }
740
-
741
- if (!isFallback) {
742
- addFontAsEmbedded(uint8, fontFileName)
743
- } else if (akariSubHandle) {
744
- addFontAsEmbedded(uint8, fontFileName)
745
- } else {
746
- pendingFallbackFonts.push({ data: uint8, name: fontFileName })
747
- }
748
- }
749
- scheduleReloadFonts()
750
- }
751
-
752
- /**
753
- * Immediate font write without debounced reload (for synchronous loading).
754
- */
755
- const writeFontToFSImmediate = (uint8: Uint8Array, isFallback: boolean = true): void => {
756
- const fontDir = isFallback ? '/fonts/fallback' : '/fonts/attached'
757
- const fontFileName = isFallback ? 'fallback-' + fallbackFontId++ : 'attached-' + attachedFontId++
758
-
759
- if (_Module) {
760
- try {
761
- _Module.FS_createDataFile(fontDir, fontFileName, uint8, true, true, true)
762
- if (debug) console.log('[AkariSub] Wrote font to FS:', fontDir + '/' + fontFileName, 'size:', uint8.length)
763
- } catch (e) {
764
- console.warn('Failed to write font to filesystem:', fontDir + '/' + fontFileName, e)
765
- }
766
-
767
- if (!isFallback) {
768
- addFontAsEmbedded(uint8, fontFileName)
769
- } else if (akariSubHandle) {
770
- addFontAsEmbedded(uint8, fontFileName)
771
- } else {
772
- pendingFallbackFonts.push({ data: uint8, name: fontFileName })
773
- }
774
- }
775
- }
776
-
777
- const processAvailableFonts = (content: string): void => {
778
- if (!availableFonts) return
779
- const isLargeFile = content.length > 500000
780
-
781
- if (isLargeFile) {
782
- // Extract only the styles section for large files
783
- const stylesMatch = content.match(/\[V4\+?\s*Styles?\][^\[]*(?=\[|$)/i)
784
- if (stylesMatch) {
785
- const stylesSection = stylesMatch[0]
786
- // Parse only the styles section
787
- const styleFontMatches = stylesSection.matchAll(/^Style:[^,]*,([^,]+)/gm)
788
- for (const match of styleFontMatches) {
789
- findAvailableFonts(match[1].trim())
790
- }
791
- }
792
-
793
- // For Events section in large files, limit to first 1000 \fn tags
794
- const eventsMatch = content.match(/\[Events\][\s\S]*/i)
795
- if (eventsMatch) {
796
- const eventsContent = eventsMatch[0]
797
- const fnMatches = eventsContent.matchAll(/\\fn([^\\}]*?)[\\}]/g)
798
- let count = 0
799
- for (const match of fnMatches) {
800
- findAvailableFonts(match[1])
801
- if (++count >= 1000) break
802
- }
803
- }
804
- } else {
805
- // Original behavior for small files
806
- const sections = parseAss(content, true)
807
-
808
- for (let i = 0; i < sections.length; i++) {
809
- for (let j = 0; j < sections[i].body.length; j++) {
810
- const entry = sections[i].body[j]
811
- if (entry.key === 'Style' && typeof entry.value === 'object' && !Array.isArray(entry.value)) {
812
- findAvailableFonts((entry.value as Record<string, string>).Fontname)
813
- }
814
- }
815
- }
816
-
817
- // Use matchAll for Events section
818
- const eventsMatch = content.match(/\[Events\][\s\S]*/i)
819
- if (eventsMatch) {
820
- const eventsContent = eventsMatch[0]
821
- const fnMatches = eventsContent.matchAll(/\\fn([^\\}]*?)[\\}]/g)
822
- for (const match of fnMatches) {
823
- findAvailableFonts(match[1])
824
- }
825
- }
826
- }
827
- }
828
-
829
- // =============================================================================
830
- // Network Utilities
831
- // =============================================================================
832
-
833
- const read_ = (url: string, ab?: boolean): string | ArrayBuffer => {
834
- const xhr = new XMLHttpRequest()
835
- xhr.open('GET', url, false)
836
- xhr.responseType = ab ? 'arraybuffer' : 'text'
837
- xhr.send(null)
838
- return xhr.response
839
- }
840
-
841
- const readAsync = (url: string, load: (data: ArrayBuffer) => void, err: (e: any) => void): void => {
842
- const xhr = new XMLHttpRequest()
843
- xhr.open('GET', url, true)
844
- xhr.responseType = 'arraybuffer'
845
- xhr.onload = () => {
846
- if ((xhr.status === 200 || xhr.status === 0) && xhr.response) {
847
- return load(xhr.response)
848
- }
849
- }
850
- xhr.onerror = err
851
- xhr.send(null)
852
- }
853
-
854
- // =============================================================================
855
- // Track Management
856
- // =============================================================================
857
-
858
- const finishTrackLoad = (): void => {
859
- const api = requireApi()
860
- const handle = requireHandle()
861
- syncTotalEventsMetric()
862
- firstTrackEventStartTime = getFirstEventStartTime()
863
- subtitleColorSpace = libassYCbCrMap[api.getTrackColorSpace(handle)]
864
- forceNextDemandRender = true
865
- postMessage({ target: 'verifyColorSpace', subtitleColorSpace })
866
- postMessage({ target: 'trackReady' })
867
- }
868
-
869
- self.setTrack = ({ content }: { content: string | Uint8Array | ArrayBuffer }): void => {
870
- stopWarmup()
871
- fullTrackWarmupPromise = null
872
- protectedTrackContent = false
873
-
874
- if (isBinaryContent(content)) {
875
- createTrackFromBytes(toUint8Array(content))
876
- finishTrackLoad()
877
- return
878
- }
879
-
880
- processAvailableFonts(content)
881
-
882
- if (clampPos) content = fixPlayRes(content)
883
- if (dropAllBlur) content = dropBlur(content)
884
-
885
- createTrackFromString(content)
886
- finishTrackLoad()
887
- }
888
-
889
- self.setEncryptedTrack = async ({ content }: { content: EncryptedSubtitleContent }): Promise<void> => {
890
- stopWarmup()
891
- fullTrackWarmupPromise = null
892
- protectedTrackContent = true
893
-
894
- const decrypted = await decryptSubtitleContent(content)
895
- try {
896
- createTrackFromBytes(decrypted)
897
- } finally {
898
- decrypted.fill(0)
899
- }
900
- finishTrackLoad()
901
- }
902
-
903
- self.getColorSpace = (): void => {
904
- postMessage({ target: 'verifyColorSpace', subtitleColorSpace })
905
- }
906
-
907
- self.freeTrack = (): void => {
908
- stopWarmup()
909
- fullTrackWarmupPromise = null
910
- firstTrackEventStartTime = null
911
- protectedTrackContent = false
912
- const api = requireApi()
913
- const handle = requireHandle()
914
- api.removeTrack(handle)
915
- syncTotalEventsMetric()
916
- }
917
-
918
- self.setTrackByUrl = ({ url }: { url: string }): void => {
919
- self.setTrack({ content: read_(url) as string })
920
- }
921
-
922
- // =============================================================================
923
- // Time Management
924
- // =============================================================================
925
-
926
- let _isPaused = true
927
-
928
- const getCurrentTime = (): number => {
929
- const diff = (nowMs() - lastCurrentTimeReceivedAt) / 1000
930
- if (_isPaused) {
931
- return lastCurrentTime
932
- } else {
933
- if (diff > 5) {
934
- console.error("Didn't receive currentTime > 5 seconds. Assuming video was paused.")
935
- setIsPaused(true)
936
- }
937
- return lastCurrentTime + diff * rate
938
- }
939
- }
940
-
941
- const setCurrentTime = (currentTime: number): void => {
942
- lastCurrentTime = currentTime
943
- lastCurrentTimeReceivedAt = nowMs()
944
-
945
- if (onDemandRenderMode) {
946
- return
947
- }
948
-
949
- if (!rafId) {
950
- if (nextIsRaf) {
951
- rafId = requestAnimationFrame(renderLoop)
952
- } else {
953
- renderLoop()
954
- nextIsRaf = true
955
- setTimeout(() => {
956
- nextIsRaf = false
957
- }, 20)
958
- }
959
- }
960
- }
961
-
962
- const setIsPaused = (isPaused: boolean): void => {
963
- if (onDemandRenderMode) {
964
- _isPaused = isPaused
965
- if (rafId) {
966
- cancelAnimationFrame(rafId)
967
- rafId = null
968
- }
969
- return
970
- }
971
-
972
- if (isPaused !== _isPaused) {
973
- _isPaused = isPaused
974
- if (isPaused) {
975
- if (rafId) {
976
- cancelAnimationFrame(rafId)
977
- rafId = null
978
- }
979
- } else {
980
- lastCurrentTimeReceivedAt = nowMs()
981
- rafId = requestAnimationFrame(renderLoop)
982
- }
983
- }
984
- }
985
-
986
- // =============================================================================
987
- // Rendering
988
- // =============================================================================
989
-
990
- interface RenderTimes {
991
- WASMRenderTime?: number
992
- WASMBitmapDecodeTime?: number
993
- JSRenderTime?: number
994
- JSBitmapGenerationTime?: number
995
- bitmaps?: number
996
- }
997
-
998
- const flushQueuedRender = (): void => {
999
- if (renderInFlight || queuedRenders.length === 0) return
1000
-
1001
- if (queuedRenders.length > 1) {
1002
- const dropped = queuedRenders.length - 1
1003
- metrics.framesDropped += dropped
1004
- const latest = queuedRenders[queuedRenders.length - 1]
1005
- queuedRenders.length = 0
1006
- queuedRenders.push(latest)
1007
- }
1008
-
1009
- const next = queuedRenders.shift()
1010
- if (!next) return
1011
- render(next.time, next.force)
1012
- }
1013
-
1014
- const completeRenderCycle = (): void => {
1015
- renderInFlight = false
1016
- flushQueuedRender()
1017
- }
1018
-
1019
- const render = (time: number, force?: boolean | number): void => {
1020
- if (renderInFlight) {
1021
- const queuedItem = { time, force: force ? 1 as const : 0 as const }
1022
-
1023
- if (queuedItem.force) {
1024
- queuedRenders.length = 0
1025
- } else {
1026
- const lastQueued = queuedRenders[queuedRenders.length - 1]
1027
- if (lastQueued && Math.abs(lastQueued.time - queuedItem.time) > 0.25) {
1028
- queuedRenders.length = 0
1029
- }
1030
- }
1031
-
1032
- if (queuedRenders.length >= MAX_QUEUED_RENDERS) {
1033
- queuedRenders.shift()
1034
- metrics.framesDropped++
1035
- }
1036
- queuedRenders.push(queuedItem)
1037
- return
1038
- }
1039
-
1040
- // Inside a known-empty window the output cannot change: skip the WASM call
1041
- if (!force && emptyWindowFrom >= 0 && time >= emptyWindowFrom && time < emptyWindowUntil) {
1042
- metrics.cacheHits++
1043
- postMessage({ target: 'unbusy' })
1044
- return
1045
- }
1046
-
1047
- renderInFlight = true
1048
- initPool() // Ensure pool is ready
1049
-
1050
- const times: RenderTimes = {}
1051
- const renderStartTime = performance.now()
1052
- metrics.renderStartTime = renderStartTime
1053
- metrics.pendingRenders++
1054
-
1055
- const api = requireApi()
1056
- const handle = requireHandle()
1057
- const forceInt = force ? 1 : 0
1058
-
1059
- // Use the batch render-collect API: single WASM call does render + metadata + image data extraction.
1060
- ensureRenderCollectBuffer(RENDER_COLLECT_MAX_IMAGES)
1061
-
1062
- const written =
1063
- blendMode === 'wasm'
1064
- ? api.renderBlendCollect(handle, time, forceInt, rrcBufPtr, rrcBufCapacity)
1065
- : api.renderImageCollect(handle, time, forceInt, rrcBufPtr, rrcBufCapacity)
1066
-
1067
- // Refresh after the WASM call: rendering may have grown the memory
1068
- refreshRrcViews()
1069
- const headerView = rrcHeaderView!
1070
- const changed = headerView[0]
1071
-
1072
- // Frame produced no images: ask how long renders can be skipped.
1073
- if (headerView[1] === 0 && (emptyWindowFrom < 0 || time < emptyWindowFrom || time >= emptyWindowUntil)) {
1074
- computeEmptyWindow(time)
1075
- }
1076
-
1077
- // Update metrics
1078
- const renderEndTime = performance.now()
1079
- const renderDuration = renderEndTime - renderStartTime
1080
- metrics.lastRenderTime = renderDuration
1081
- metrics.totalRenderTime += renderDuration
1082
- metrics.maxRenderTime = Math.max(metrics.maxRenderTime, renderDuration)
1083
- if (renderDuration > 0) {
1084
- metrics.minRenderTime = Math.min(metrics.minRenderTime, renderDuration)
1085
- }
1086
-
1087
- if (changed !== 0 || force) {
1088
- metrics.framesRendered++
1089
- metrics.cacheMisses++
1090
- } else {
1091
- metrics.cacheHits++
1092
- }
1093
-
1094
- if (debug) {
1095
- const decodeEndTime = performance.now()
1096
- const renderEndTimeWasm = headerView[2]
1097
- times.WASMRenderTime = renderEndTimeWasm - renderStartTime
1098
- times.WASMBitmapDecodeTime = decodeEndTime - renderEndTimeWasm
1099
- times.JSRenderTime = Date.now()
1100
- }
1101
-
1102
- if (changed !== 0 || force) {
1103
- const images = frameImages
1104
- const buffers = frameArrayBuffers
1105
- images.length = 0
1106
- buffers.length = 0
1107
-
1108
- if (written === 0) return paintImages({ images, buffers, times })
1109
-
1110
- const meta = rrcMetaView!
1111
-
1112
- const useAsyncBitmapPath = asyncRender && offscreenRender !== true
1113
-
1114
- if (useAsyncBitmapPath) {
1115
- const promises = frameBitmapPromises
1116
- promises.length = written
1117
-
1118
- for (let i = 0; i < written; ++i) {
1119
- const metaOffset = i * RRC_IMG_STRIDE
1120
- const item = getPooledItem(i)
1121
- item.x = meta[metaOffset]
1122
- item.y = meta[metaOffset + 1]
1123
- item.w = meta[metaOffset + 2]
1124
- item.h = meta[metaOffset + 3]
1125
- item.image = 0
1126
-
1127
- const pointer = meta[metaOffset + 4]
1128
- const byteLength = item.w * item.h * 4
1129
- let rawData = new Uint8ClampedArray(self.wasmMemory.buffer, pointer, byteLength)
1130
- if (hasBitmapBug) {
1131
- // Browsers with the partial-bitmap bug mis-render ImageData backed
1132
- // by a view into a larger buffer; give them a standalone copy.
1133
- rawData = rawData.slice()
1134
- }
1135
-
1136
- const imageData = new ImageData(rawData as Uint8ClampedArray<ArrayBuffer>, item.w, item.h)
1137
-
1138
- promises[i] = asyncRenderOptions
1139
- ? createImageBitmap(imageData, { premultiplyAlpha: 'none', colorSpaceConversion: 'none' })
1140
- : createImageBitmap(imageData)
1141
- images[i] = item
1142
- }
1143
-
1144
- Promise.all(promises).then((bitmaps) => {
1145
- for (let i = 0; i < written; i++) {
1146
- images[i].image = bitmaps[i]
1147
- }
1148
- if (debug) times.JSBitmapGenerationTime = Date.now() - (times.JSRenderTime || 0)
1149
- paintImages({ images, buffers: bitmaps, times })
1150
- }).catch(() => {
1151
- if (asyncRenderOptions) {
1152
- asyncRenderOptions = false
1153
- console.warn('[AkariSub] createImageBitmap options not supported, disabling')
1154
- metrics.pendingRenders--
1155
- completeRenderCycle()
1156
- render(time, force)
1157
- } else {
1158
- metrics.pendingRenders--
1159
- postMessage({ target: 'unbusy' })
1160
- completeRenderCycle()
1161
- }
1162
- })
1163
- } else {
1164
- // When posting to the main thread, copy all image pixels into one
1165
- // transferable buffer instead of allocating/transferring one per image.
1166
- let copyTarget: Uint8ClampedArray<ArrayBuffer> | null = null
1167
- let copyOffset = 0
1168
- if (!offCanvasCtx) {
1169
- let totalBytes = 0
1170
- for (let i = 0; i < written; ++i) {
1171
- const metaOffset = i * RRC_IMG_STRIDE
1172
- totalBytes += meta[metaOffset + 2] * meta[metaOffset + 3] * 4
1173
- }
1174
- copyTarget = new Uint8ClampedArray(totalBytes)
1175
- buffers.push(copyTarget.buffer)
1176
- }
1177
-
1178
- for (let i = 0; i < written; ++i) {
1179
- const metaOffset = i * RRC_IMG_STRIDE
1180
- const item = getPooledItem(i)
1181
- item.x = meta[metaOffset]
1182
- item.y = meta[metaOffset + 1]
1183
- item.w = meta[metaOffset + 2]
1184
- item.h = meta[metaOffset + 3]
1185
- item.image = meta[metaOffset + 4]
1186
-
1187
- if (copyTarget) {
1188
- const imagePtr = item.image as number
1189
- const byteLength = item.w * item.h * 4
1190
- const copiedData = copyTarget.subarray(copyOffset, copyOffset + byteLength)
1191
- copiedData.set(self.HEAPU8C.subarray(imagePtr, imagePtr + byteLength))
1192
- item.image = copiedData
1193
- copyOffset += byteLength
1194
- }
1195
- images[i] = item
1196
- }
1197
- paintImages({ images, buffers, times })
1198
- }
1199
- } else {
1200
- metrics.pendingRenders--
1201
- postMessage({ target: 'unbusy' })
1202
- completeRenderCycle()
1203
- }
1204
- }
1205
-
1206
- self.demand = ({ time }: { time: number }): void => {
1207
- lastCurrentTime = time
1208
- lastCurrentTimeReceivedAt = nowMs()
1209
- const force = forceNextDemandRender ? 1 : 0
1210
- forceNextDemandRender = false
1211
- render(time, force)
1212
- }
1213
-
1214
- const renderLoop = (force?: boolean | number): void => {
1215
- rafId = null
1216
- render(getCurrentTime(), force)
1217
- if (!_isPaused) {
1218
- rafId = requestAnimationFrame(renderLoop)
1219
- }
1220
- }
1221
-
1222
- const paintImages = ({
1223
- times,
1224
- images,
1225
- buffers
1226
- }: {
1227
- times: RenderTimes
1228
- images: RenderResultItem[]
1229
- buffers: (ArrayBuffer | ImageBitmap)[]
1230
- }): void => {
1231
- metrics.pendingRenders--
1232
-
1233
- const width = self.width
1234
- const height = self.height
1235
- const imageCount = images.length
1236
-
1237
- const resultObject = {
1238
- target: 'render',
1239
- asyncRender,
1240
- images,
1241
- times,
1242
- width,
1243
- height,
1244
- colorSpace: subtitleColorSpace
1245
- }
1246
-
1247
- if (offscreenRender) {
1248
- // Only resize canvas when dimensions actually change
1249
- if (offCanvas!.height !== height || offCanvas!.width !== width) {
1250
- offCanvas!.width = width
1251
- offCanvas!.height = height
1252
- }
1253
- offCanvasCtx!.clearRect(0, 0, width, height)
1254
-
1255
- if (asyncRender) {
1256
- // Batch draw all images
1257
- for (let i = 0; i < imageCount; i++) {
1258
- const img = images[i]
1259
- if (img.image) {
1260
- offCanvasCtx!.drawImage(img.image as ImageBitmap, img.x, img.y)
1261
- ; (img.image as ImageBitmap).close()
1262
- }
1263
- }
1264
- } else {
1265
- // Non-async path with buffer canvas
1266
- for (let i = 0; i < imageCount; i++) {
1267
- const img = images[i]
1268
- if (img.image) {
1269
- const imgW = img.w
1270
- const imgH = img.h
1271
-
1272
- // Only resize buffer canvas when needed
1273
- if (bufferCanvas!.width !== imgW || bufferCanvas!.height !== imgH) {
1274
- bufferCanvas!.width = imgW
1275
- bufferCanvas!.height = imgH
1276
- }
1277
-
1278
- const pointer = img.image as number
1279
- const byteLength = imgW * imgH * 4
1280
- const rawData = self.HEAPU8C.subarray(pointer, pointer + byteLength)
1281
-
1282
- bufferCtx!.putImageData(
1283
- new ImageData(
1284
- rawData as Uint8ClampedArray<ArrayBuffer>,
1285
- imgW,
1286
- imgH
1287
- ),
1288
- 0,
1289
- 0
1290
- )
1291
- offCanvasCtx!.drawImage(bufferCanvas!, img.x, img.y)
1292
- }
1293
- }
1294
- }
1295
-
1296
- if (offscreenRender === 'hybrid') {
1297
- if (!imageCount) {
1298
- postMessage(resultObject)
1299
- completeRenderCycle()
1300
- return
1301
- }
1302
- if (debug) times.bitmaps = imageCount
1303
- try {
1304
- const bitmap = offCanvas!.transferToImageBitmap()
1305
- const result = {
1306
- ...resultObject,
1307
- images: [{ image: bitmap, x: 0, y: 0 }],
1308
- asyncRender: true
1309
- }
1310
- postMessage(result, [bitmap])
1311
- completeRenderCycle()
1312
- } catch {
1313
- postMessage({ target: 'unbusy' })
1314
- completeRenderCycle()
1315
- }
1316
- } else {
1317
- if (debug) {
1318
- times.JSRenderTime = Date.now() - (times.JSRenderTime || 0) - (times.JSBitmapGenerationTime || 0)
1319
- let total = 0
1320
- for (const key in times) total += (times as any)[key] || 0
1321
- console.log('Bitmaps: ' + imageCount + ' Total: ' + (total | 0) + 'ms', times)
1322
- }
1323
- postMessage({ target: 'unbusy' })
1324
- completeRenderCycle()
1325
- }
1326
- } else {
1327
- postMessage(resultObject, buffers as Transferable[])
1328
- completeRenderCycle()
1329
- }
1330
- }
1331
-
1332
- // Custom requestAnimationFrame for worker
1333
- const requestAnimationFrame = self.requestAnimationFrame ? self.requestAnimationFrame.bind(self) : ((): ((func: () => void) => number) => {
1334
- let nextRAF = 0
1335
- return (func: () => void): number => {
1336
- const now = nowMs()
1337
- if (nextRAF === 0) {
1338
- nextRAF = now + 1000 / targetFps
1339
- } else {
1340
- while (now + 2 >= nextRAF) {
1341
- nextRAF += 1000 / targetFps
1342
- }
1343
- }
1344
- const delay = Math.max(nextRAF - now, 0)
1345
- return setTimeout(func, delay) as unknown as number
1346
- }
1347
- })()
1348
-
1349
- const cancelAnimationFrame = self.cancelAnimationFrame ? self.cancelAnimationFrame.bind(self) : clearTimeout
1350
-
1351
- // =============================================================================
1352
- // WASM Initialization
1353
- // =============================================================================
1354
-
1355
- self.init = async (data: any): Promise<void> => {
1356
- hasBitmapBug = data.hasBitmapBug
1357
- fullTrackWarmupEnabled = !!data.fullTrackWarmup
1358
- if (typeof data.initialTime === 'number' && Number.isFinite(data.initialTime)) {
1359
- lastCurrentTime = data.initialTime
1360
- }
1361
-
1362
- const _fetch = self.fetch
1363
- const setWasmUrl = (wasmUrl: string): void => {
1364
- if ((WebAssembly as any).instantiateStreaming) {
1365
- self.fetch = (_: any) => _fetch(wasmUrl)
1366
- }
1367
- }
1368
-
1369
- const restoreFetch = (): void => {
1370
- self.fetch = _fetch
1371
- }
1372
-
1373
- const loadWasm = (wasmUrl: string): Promise<AkariSubModule> => {
1374
- setWasmUrl(wasmUrl)
1375
- return WASM({
1376
- wasm: !(WebAssembly as any).instantiateStreaming ? (read_(wasmUrl, true) as ArrayBuffer) : undefined
1377
- }).finally(restoreFetch)
1378
- }
1379
-
1380
- const onWasmLoaded = async (Module: AkariSubModule): Promise<void> => {
1381
- _Module = Module // Store module reference for FS access
1382
-
1383
- akariSubApi = {
1384
- create: Module._akarisub_create,
1385
- destroy: Module._akarisub_destroy,
1386
- setDropAnimations: Module._akarisub_set_drop_animations,
1387
- createTrackMem: Module._akarisub_create_track_mem,
1388
- removeTrack: Module._akarisub_remove_track,
1389
- resizeCanvas: Module._akarisub_resize_canvas,
1390
- addFont: Module._akarisub_add_font,
1391
- reloadFonts: Module._akarisub_reload_fonts,
1392
- setDefaultFont: Module._akarisub_set_default_font,
1393
- setFallbackFonts: Module._akarisub_set_fallback_fonts,
1394
- setMemoryLimits: Module._akarisub_set_memory_limits,
1395
- getEventCount: Module._akarisub_get_event_count,
1396
- allocEvent: Module._akarisub_alloc_event,
1397
- removeEvent: Module._akarisub_remove_event,
1398
- getStyleCount: Module._akarisub_get_style_count,
1399
- allocStyle: Module._akarisub_alloc_style,
1400
- removeStyle: Module._akarisub_remove_style,
1401
- styleOverrideIndex: Module._akarisub_style_override_index,
1402
- disableStyleOverride: Module._akarisub_disable_style_override,
1403
- getTrackColorSpace: Module._akarisub_get_track_color_space,
1404
- eventGetInt: Module._akarisub_event_get_int,
1405
- eventSetInt: Module._akarisub_event_set_int,
1406
- eventGetStr: Module._akarisub_event_get_str,
1407
- eventSetStr: Module._akarisub_event_set_str,
1408
- styleGetNum: Module._akarisub_style_get_num,
1409
- styleSetNum: Module._akarisub_style_set_num,
1410
- styleGetStr: Module._akarisub_style_get_str,
1411
- styleSetStr: Module._akarisub_style_set_str,
1412
- getEventTimeRange: Module._akarisub_get_event_time_range,
1413
- getEmptyWindow: Module._akarisub_get_empty_window,
1414
- renderBlendCollect: Module._akarisub_render_blend_collect,
1415
- renderImageCollect: Module._akarisub_render_image_collect
1416
- }
1417
-
1418
- // Normalize fallback fonts and deduplicate
1419
- const fallbackFonts: string[] = []
1420
- const fallbackFontKeys = new Set<string>()
1421
- if (data.fallbackFonts && data.fallbackFonts.length > 0) {
1422
- for (const font of data.fallbackFonts) {
1423
- const originalFont = font.trim()
1424
- const key = originalFont.toLowerCase()
1425
- if (key && !fallbackFontKeys.has(key)) {
1426
- fallbackFontKeys.add(key)
1427
- fallbackFonts.push(originalFont)
1428
- }
1429
- }
1430
- }
1431
-
1432
- try {
1433
- Module.FS_createPath('/', 'fonts', true, true)
1434
- Module.FS_createPath('/fonts', 'attached', true, true)
1435
- Module.FS_createPath('/fonts', 'fallback', true, true)
1436
- Module.FS_createPath('/', 'fontconfig', true, true)
1437
- Module.FS_createPath('/', 'assets', true, true)
1438
- Module.FS_createPath('/', 'etc', true, true)
1439
- Module.FS_createPath('/etc', 'fonts', true, true)
1440
-
1441
- const fontsConf = `<?xml version="1.0"?>
1442
- <!DOCTYPE fontconfig SYSTEM "fonts.dtd">
1443
- <fontconfig>
1444
- <!-- Font directories listed in priority order -->
1445
- <dir>/fonts/attached</dir>
1446
- <dir>/fonts</dir>
1447
- <dir>/fonts/fallback</dir>
1448
- <match target="pattern">
1449
- <test qual="any" name="family">
1450
- <string>mono</string>
1451
- </test>
1452
- <edit name="family" mode="assign" binding="same">
1453
- <string>monospace</string>
1454
- </edit>
1455
- </match>
1456
- <match target="pattern">
1457
- <test qual="any" name="family">
1458
- <string>sans serif</string>
1459
- </test>
1460
- <edit name="family" mode="assign" binding="same">
1461
- <string>sans-serif</string>
1462
- </edit>
1463
- </match>
1464
- <match target="pattern">
1465
- <test qual="any" name="family">
1466
- <string>sans</string>
1467
- </test>
1468
- <edit name="family" mode="assign" binding="same">
1469
- <string>sans-serif</string>
1470
- </edit>
1471
- </match>
1472
- <cachedir>/fontconfig</cachedir>
1473
- <config>
1474
- <rescan>
1475
- <int>0</int>
1476
- </rescan>
1477
- </config>
1478
- </fontconfig>
1479
- `
1480
- const fontsConfData = TEXT_ENCODER.encode(fontsConf)
1481
- Module.FS_createDataFile('/assets', 'fonts.conf', fontsConfData, true, false, false)
1482
- Module.FS_createDataFile('/etc/fonts', 'fonts.conf', fontsConfData, true, false, false)
1483
- } catch (e) {
1484
- console.warn('Failed to create font directories or fonts.conf:', e)
1485
- }
1486
-
1487
- self.width = data.width
1488
- self.height = data.height
1489
- onDemandRenderMode = !!data.onDemandRender
1490
- blendMode = data.blendMode
1491
- asyncRender = data.asyncRender
1492
-
1493
- if (asyncRender && typeof createImageBitmap === 'undefined') {
1494
- asyncRender = false
1495
- console.error("'createImageBitmap' needed for 'asyncRender' unsupported!")
1496
- }
1497
-
1498
- if (asyncRender) {
1499
- try {
1500
- const testCanvas = new OffscreenCanvas(1, 1)
1501
- const testCtx = testCanvas.getContext('2d')
1502
- if (testCtx) {
1503
- const testData = testCtx.getImageData(0, 0, 1, 1)
1504
- await createImageBitmap(testData, { premultiplyAlpha: 'none', colorSpaceConversion: 'none' })
1505
- .catch(() => {
1506
- asyncRenderOptions = false
1507
- console.warn('[AkariSub] createImageBitmap options not supported (Safari?), rendering without options')
1508
- })
1509
- }
1510
- } catch {
1511
- asyncRenderOptions = false
1512
- }
1513
- }
1514
-
1515
- availableFonts = data.availableFonts
1516
- debug = data.debug
1517
- targetFps = data.targetFps || targetFps
1518
- useLocalFonts = data.useLocalFonts
1519
- dropAllBlur = data.dropAllBlur
1520
- clampPos = data.clampPos
1521
-
1522
- // Load fallback fonts asynchronously to avoid blocking worker thread
1523
- // This is critical for mobile devices where sync XHR can cause timeouts
1524
- const loadFallbackFontsAsync = async (): Promise<void> => {
1525
- const fontPromises: Promise<void>[] = []
1526
-
1527
- for (const font of fallbackFonts) {
1528
- const fontLower = font.trim().toLowerCase()
1529
- const fontKey = fontLower.startsWith('@') ? fontLower.substring(1) : fontLower
1530
- if (availableFonts && availableFonts[fontKey]) {
1531
- const fontUrl = availableFonts[fontKey]
1532
- if (typeof fontUrl === 'string') {
1533
- // Async fetch for URL-based fonts
1534
- const promise = new Promise<void>((resolve) => {
1535
- readAsync(
1536
- fontUrl,
1537
- (fontData: ArrayBuffer) => {
1538
- writeFontToFSImmediate(new Uint8Array(fontData), true)
1539
- fontMap_[fontKey] = true
1540
- if (debug) console.log('[AkariSub] Loaded fallback font async:', fontKey)
1541
- resolve()
1542
- },
1543
- (e) => {
1544
- console.error('Failed to load fallback font:', fontKey, e)
1545
- resolve() // Don't fail initialization if a single font fails
1546
- }
1547
- )
1548
- })
1549
- fontPromises.push(promise)
1550
- } else {
1551
- // Font data directly provided - synchronous write is OK here
1552
- writeFontToFSImmediate(fontUrl, true)
1553
- fontMap_[fontKey] = true
1554
- }
1555
- }
1556
- }
1557
-
1558
- // Wait for all fonts to load (with 30s timeout to prevent blocking forever)
1559
- if (fontPromises.length > 0) {
1560
- let timeoutId: ReturnType<typeof setTimeout> | null = null
1561
- let timedOut = false
1562
- const timeoutPromise = new Promise<void>((resolve) => {
1563
- timeoutId = setTimeout(() => {
1564
- timedOut = true
1565
- console.warn('[AkariSub] Fallback font loading timeout, continuing with available fonts')
1566
- resolve()
1567
- }, 30000)
1568
- })
1569
- await Promise.race([
1570
- Promise.all(fontPromises).then(() => {
1571
- if (timeoutId !== null) clearTimeout(timeoutId)
1572
- }),
1573
- timeoutPromise
1574
- ])
1575
- if (!timedOut && debug) {
1576
- console.log('[AkariSub] All fallback fonts loaded successfully')
1577
- }
1578
- }
1579
- }
1580
-
1581
- await loadFallbackFontsAsync()
1582
-
1583
- const primaryFallback = fallbackFonts.length > 0 ? fallbackFonts[0] : null
1584
- akariSubHandle = withCString(primaryFallback || '', (fontPtr) => {
1585
- return requireApi().create(self.width, self.height, fontPtr, debug ? 1 : 0)
1586
- })
1587
-
1588
- if (pendingFallbackFonts.length > 0) {
1589
- for (const { data: fontData, name: fontName } of pendingFallbackFonts) {
1590
- addFontAsEmbedded(fontData, fontName)
1591
- }
1592
- pendingFallbackFonts.length = 0
1593
- requireApi().reloadFonts(akariSubHandle)
1594
- }
1595
-
1596
- if (fallbackFonts.length > 0) {
1597
- withCString(fallbackFonts.join(','), (fontsPtr) => {
1598
- requireApi().setFallbackFonts(requireHandle(), fontsPtr)
1599
- })
1600
- }
1601
-
1602
- let subContent = data.subContent
1603
- let decryptedSubContent: Uint8Array | null = null
1604
-
1605
- if (data.encryptedSubContent) {
1606
- protectedTrackContent = true
1607
- decryptedSubContent = await decryptSubtitleContent(data.encryptedSubContent)
1608
- subContent = decryptedSubContent
1609
- } else {
1610
- protectedTrackContent = false
1611
- if (!subContent) subContent = read_(data.subUrl) as string
1612
- }
1613
-
1614
- // For large files, emit partial_ready early to allow playback to start
1615
- // while font loading and track parsing continues in the background
1616
- const isLargeSubtitle = typeof subContent === 'string'
1617
- ? subContent.length > 500000
1618
- : toUint8Array(subContent).byteLength > 500000
1619
- if (isLargeSubtitle) {
1620
- postMessage({ target: 'partial_ready' })
1621
- if (debug) console.log('[AkariSub] Large subtitle detected, emitting partial_ready early')
1622
- }
1623
-
1624
- if (typeof subContent === 'string') {
1625
- processAvailableFonts(subContent)
1626
- if (clampPos) subContent = fixPlayRes(subContent)
1627
- if (dropAllBlur) subContent = dropBlur(subContent)
1628
- } else if (debug && (clampPos || dropAllBlur)) {
1629
- console.warn('[AkariSub] Text rewrite options are skipped for protected binary subtitle content')
1630
- }
1631
-
1632
- // Load attached/preloaded fonts before ready to avoid runtime font churn during first playback.
1633
- let hasAttachedFonts = false
1634
- const attachedFontPromises: Promise<void>[] = []
1635
-
1636
- for (const font of data.fonts || []) {
1637
- if (typeof font === 'string') {
1638
- const promise = new Promise<void>((resolve) => {
1639
- readAsync(
1640
- font,
1641
- (fontData: ArrayBuffer) => {
1642
- writeFontToFSImmediate(new Uint8Array(fontData), false)
1643
- hasAttachedFonts = true
1644
- if (debug) console.log('[AkariSub] Loaded attached font async:', font)
1645
- resolve()
1646
- },
1647
- (e) => {
1648
- console.error('Failed to load attached font:', font, e)
1649
- resolve()
1650
- }
1651
- )
1652
- })
1653
- attachedFontPromises.push(promise)
1654
- } else {
1655
- writeFontToFSImmediate(font, false)
1656
- hasAttachedFonts = true
1657
- }
1658
- }
1659
-
1660
- if (attachedFontPromises.length > 0) {
1661
- let attachedTimeoutId: ReturnType<typeof setTimeout> | null = null
1662
- let attachedTimedOut = false
1663
- const attachedTimeoutPromise = new Promise<void>((resolve) => {
1664
- attachedTimeoutId = setTimeout(() => {
1665
- attachedTimedOut = true
1666
- console.warn('[AkariSub] Attached font loading timeout, continuing with available fonts')
1667
- resolve()
1668
- }, 30000)
1669
- })
1670
-
1671
- await Promise.race([
1672
- Promise.all(attachedFontPromises).then(() => {
1673
- if (attachedTimeoutId !== null) clearTimeout(attachedTimeoutId)
1674
- }),
1675
- attachedTimeoutPromise
1676
- ])
1677
-
1678
- if (!attachedTimedOut && debug) {
1679
- console.log('[AkariSub] Attached font loading complete')
1680
- }
1681
- }
1682
-
1683
- if (hasAttachedFonts) {
1684
- if (debug) console.log('[AkariSub] Reloading fonts after writing attached fonts to FS')
1685
- requireApi().reloadFonts(requireHandle())
1686
- if (debug) console.log('[AkariSub] Font reload complete')
1687
- }
1688
-
1689
- if (typeof subContent === 'string') {
1690
- createTrackFromString(subContent)
1691
- } else {
1692
- try {
1693
- createTrackFromBytes(toUint8Array(subContent))
1694
- } finally {
1695
- decryptedSubContent?.fill(0)
1696
- }
1697
- }
1698
- syncTotalEventsMetric()
1699
- firstTrackEventStartTime = getFirstEventStartTime()
1700
- subtitleColorSpace = libassYCbCrMap[requireApi().getTrackColorSpace(requireHandle())]
1701
- requireApi().setDropAnimations(requireHandle(), data.dropAllAnimations || 0)
1702
-
1703
- if (data.libassMemoryLimit > 0 || data.libassGlyphLimit > 0) {
1704
- requireApi().setMemoryLimits(requireHandle(), data.libassGlyphLimit || 0, data.libassMemoryLimit || 0)
1705
- }
1706
-
1707
- initPool()
1708
- ensureRenderCollectBuffer(PREWARM_MAX_IMAGES)
1709
-
1710
- try {
1711
- prewarmRenderer(lastCurrentTime)
1712
- } catch (e) {
1713
- if (debug) console.warn('[AkariSub] Prewarm render failed, continuing:', e)
1714
- }
1715
-
1716
- forceNextDemandRender = true
1717
-
1718
- postMessage({ target: 'ready' })
1719
- postMessage({ target: 'verifyColorSpace', subtitleColorSpace })
1720
- scheduleFullTrackWarmup()
1721
- }
1722
-
1723
- loadWasm(data.wasmUrl).then(onWasmLoaded).catch((e) => {
1724
- console.error('[AkariSub] WASM loading failed:', e)
1725
- postMessage({ target: 'error', error: 'WASM loading failed: ' + (e && e.message ? e.message : String(e)) })
1726
- })
1727
- }
1728
-
1729
- // =============================================================================
1730
- // Canvas Management
1731
- // =============================================================================
1732
-
1733
- self.offscreenCanvas = ({ transferable }: { transferable: [OffscreenCanvas] }): void => {
1734
- offCanvas = transferable[0]
1735
- offCanvasCtx = offCanvas.getContext('2d', { desynchronized: true })
1736
- if (!asyncRender) {
1737
- bufferCanvas = new OffscreenCanvas(self.width, self.height)
1738
- bufferCtx = bufferCanvas.getContext('2d', { desynchronized: true })
1739
- }
1740
- offscreenRender = true
1741
- }
1742
-
1743
- self.detachOffscreen = (): void => {
1744
- offCanvas = new OffscreenCanvas(self.width, self.height)
1745
- offCanvasCtx = offCanvas.getContext('2d', { desynchronized: true })
1746
- offscreenRender = 'hybrid'
1747
- }
1748
-
1749
- self.canvas = ({
1750
- width,
1751
- height,
1752
- videoWidth,
1753
- videoHeight,
1754
- force
1755
- }: {
1756
- width: number
1757
- height: number
1758
- videoWidth: number
1759
- videoHeight: number
1760
- force?: boolean
1761
- }): void => {
1762
- if (width == null) throw new Error('Invalid canvas size specified')
1763
- self.width = width
1764
- self.height = height
1765
- if (akariSubHandle) requireApi().resizeCanvas(akariSubHandle, width, height, videoWidth, videoHeight)
1766
- if (force) render(lastCurrentTime, true)
1767
- }
1768
-
1769
- self.video = ({
1770
- currentTime,
1771
- isPaused,
1772
- rate: newRate
1773
- }: {
1774
- currentTime?: number
1775
- isPaused?: boolean
1776
- rate?: number
1777
- }): void => {
1778
- if (currentTime != null) setCurrentTime(currentTime)
1779
- if (isPaused != null) setIsPaused(isPaused)
1780
- if (newRate != null) rate = newRate
1781
- }
1782
-
1783
- self.destroy = (): void => {
1784
- stopWarmup()
1785
- fullTrackWarmupPromise = null
1786
- firstTrackEventStartTime = null
1787
-
1788
- if (_Module) {
1789
- if (rrcBufPtr) {
1790
- _Module._free(rrcBufPtr)
1791
- rrcBufPtr = 0
1792
- rrcBufCapacity = 0
1793
- rrcHeaderView = null
1794
- rrcMetaView = null
1795
- rrcViewsBuffer = null
1796
- rrcViewsPtr = 0
1797
- rrcViewsCapacity = 0
1798
- }
1799
- }
1800
- if (akariSubHandle) {
1801
- requireApi().destroy(akariSubHandle)
1802
- akariSubHandle = 0
1803
- }
1804
- metrics.totalEvents = 0
1805
- }
1806
-
1807
- self.setAsyncRender = ({ value }: { value: boolean }): void => {
1808
- asyncRender = value && typeof createImageBitmap !== 'undefined'
1809
- }
1810
-
1811
- // =============================================================================
1812
- // Event Management
1813
- // =============================================================================
1814
-
1815
- const applyEventFields = (index: number, event: Partial<ASSEvent>): void => {
1816
- const api = requireApi()
1817
- const handle = requireHandle()
1818
- for (const key of Object.keys(event) as (keyof ASSEvent)[]) {
1819
- const value = event[key]
1820
- if (value == null || key === '_index') continue
1821
-
1822
- if (key in EVENT_INT_FIELDS) {
1823
- api.eventSetInt(handle, index, EVENT_INT_FIELDS[key as string], Number(value))
1824
- continue
1825
- }
1826
-
1827
- if (key in EVENT_STR_FIELDS) {
1828
- withCString(String(value), (ptr) => {
1829
- api.eventSetStr(handle, index, EVENT_STR_FIELDS[key as string], ptr)
1830
- })
1831
- }
1832
- }
1833
- }
1834
-
1835
- const readEvent = (index: number): ASSEvent => {
1836
- const api = requireApi()
1837
- const handle = requireHandle()
1838
- return {
1839
- Start: api.eventGetInt(handle, index, EVENT_INT_FIELDS.Start),
1840
- Duration: api.eventGetInt(handle, index, EVENT_INT_FIELDS.Duration),
1841
- ReadOrder: api.eventGetInt(handle, index, EVENT_INT_FIELDS.ReadOrder),
1842
- Layer: api.eventGetInt(handle, index, EVENT_INT_FIELDS.Layer),
1843
- Style: String(api.eventGetInt(handle, index, EVENT_INT_FIELDS.Style)),
1844
- MarginL: api.eventGetInt(handle, index, EVENT_INT_FIELDS.MarginL),
1845
- MarginR: api.eventGetInt(handle, index, EVENT_INT_FIELDS.MarginR),
1846
- MarginV: api.eventGetInt(handle, index, EVENT_INT_FIELDS.MarginV),
1847
- Name: readCString(api.eventGetStr(handle, index, EVENT_STR_FIELDS.Name)),
1848
- Text: readCString(api.eventGetStr(handle, index, EVENT_STR_FIELDS.Text)),
1849
- Effect: readCString(api.eventGetStr(handle, index, EVENT_STR_FIELDS.Effect))
1850
- }
1851
- }
1852
-
1853
- const applyStyleFields = (index: number, style: Partial<ASSStyle>): void => {
1854
- const api = requireApi()
1855
- const handle = requireHandle()
1856
- for (const key of Object.keys(style) as (keyof ASSStyle)[]) {
1857
- const value = style[key]
1858
- if (value == null) continue
1859
-
1860
- if (key in STYLE_NUM_FIELDS) {
1861
- api.styleSetNum(handle, index, STYLE_NUM_FIELDS[key as string], Number(value))
1862
- continue
1863
- }
1864
-
1865
- if (key in STYLE_STR_FIELDS) {
1866
- withCString(String(value), (ptr) => {
1867
- api.styleSetStr(handle, index, STYLE_STR_FIELDS[key as string], ptr)
1868
- })
1869
- }
1870
- }
1871
- }
1872
-
1873
- const readStyle = (index: number): ASSStyle => {
1874
- const api = requireApi()
1875
- const handle = requireHandle()
1876
- return {
1877
- Name: readCString(api.styleGetStr(handle, index, STYLE_STR_FIELDS.Name)),
1878
- FontName: readCString(api.styleGetStr(handle, index, STYLE_STR_FIELDS.FontName)),
1879
- FontSize: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.FontSize),
1880
- PrimaryColour: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.PrimaryColour),
1881
- SecondaryColour: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.SecondaryColour),
1882
- OutlineColour: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.OutlineColour),
1883
- BackColour: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.BackColour),
1884
- Bold: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.Bold),
1885
- Italic: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.Italic),
1886
- Underline: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.Underline),
1887
- StrikeOut: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.StrikeOut),
1888
- ScaleX: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.ScaleX),
1889
- ScaleY: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.ScaleY),
1890
- Spacing: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.Spacing),
1891
- Angle: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.Angle),
1892
- BorderStyle: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.BorderStyle),
1893
- Outline: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.Outline),
1894
- Shadow: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.Shadow),
1895
- Alignment: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.Alignment),
1896
- MarginL: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.MarginL),
1897
- MarginR: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.MarginR),
1898
- MarginV: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.MarginV),
1899
- Encoding: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.Encoding),
1900
- treat_fontname_as_pattern: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.treat_fontname_as_pattern),
1901
- Blur: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.Blur),
1902
- Justify: api.styleGetNum(handle, index, STYLE_NUM_FIELDS.Justify)
1903
- }
1904
- }
1905
-
1906
- self.createEvent = ({ event }: { event: Partial<ASSEvent> }): void => {
1907
- const index = requireApi().allocEvent(requireHandle())
1908
- if (index >= 0) applyEventFields(index, event)
1909
- syncTotalEventsMetric()
1910
- }
1911
-
1912
- self.getEvents = (): void => {
1913
- const events: ASSEvent[] = []
1914
- const api = requireApi()
1915
- const count = api.getEventCount(requireHandle())
1916
- for (let i = 0; i < count; i++) {
1917
- const event = { ...readEvent(i), _index: i }
1918
- if (protectedTrackContent) {
1919
- event.Name = ''
1920
- event.Effect = ''
1921
- event.Text = ''
1922
- }
1923
- events.push(event)
1924
- }
1925
- postMessage({ target: 'getEvents', events })
1926
- }
1927
-
1928
- self.setEvent = ({ event, index }: { event: Partial<ASSEvent>; index: number }): void => {
1929
- applyEventFields(index, event)
1930
- }
1931
-
1932
- self.removeEvent = ({ index }: { index: number }): void => {
1933
- requireApi().removeEvent(requireHandle(), index)
1934
- syncTotalEventsMetric()
1935
- }
1936
-
1937
- // =============================================================================
1938
- // Style Management
1939
- // =============================================================================
1940
-
1941
- self.createStyle = ({ style }: { style: Partial<ASSStyle> }): any => {
1942
- const index = requireApi().allocStyle(requireHandle())
1943
- if (index >= 0) applyStyleFields(index, style)
1944
- return index
1945
- }
1946
-
1947
- self.getStyles = (): void => {
1948
- const styles: ASSStyle[] = []
1949
- const api = requireApi()
1950
- const count = api.getStyleCount(requireHandle())
1951
- for (let i = 0; i < count; i++) {
1952
- styles.push(readStyle(i))
1953
- }
1954
- postMessage({ target: 'getStyles', time: Date.now(), styles })
1955
- }
1956
-
1957
- self.setStyle = ({ style, index }: { style: Partial<ASSStyle>; index: number }): void => {
1958
- applyStyleFields(index, style)
1959
- }
1960
-
1961
- self.removeStyle = ({ index }: { index: number }): void => {
1962
- requireApi().removeStyle(requireHandle(), index)
1963
- }
1964
-
1965
- self.styleOverride = (data: { style: Partial<ASSStyle> }): void => {
1966
- const index = self.createStyle(data)
1967
- if (typeof index === 'number' && index >= 0) {
1968
- requireApi().styleOverrideIndex(requireHandle(), index)
1969
- }
1970
- }
1971
-
1972
- self.disableStyleOverride = (): void => {
1973
- requireApi().disableStyleOverride(requireHandle())
1974
- }
1975
-
1976
- self.defaultFont = ({ font }: { font: string }): void => {
1977
- withCString(font, (fontPtr) => {
1978
- requireApi().setDefaultFont(requireHandle(), fontPtr)
1979
- })
1980
- }
1981
-
1982
- // =============================================================================
1983
- // Performance Metrics
1984
- // =============================================================================
1985
-
1986
- self.getStats = (): void => {
1987
- const avgRenderTime = metrics.framesRendered > 0 ? metrics.totalRenderTime / metrics.framesRendered : 0
1988
-
1989
- postMessage({
1990
- target: 'getStats',
1991
- stats: {
1992
- framesRendered: metrics.framesRendered,
1993
- framesDropped: metrics.framesDropped,
1994
- avgRenderTime: Math.round(avgRenderTime * 100) / 100,
1995
- maxRenderTime: Math.round(metrics.maxRenderTime * 100) / 100,
1996
- minRenderTime: metrics.minRenderTime === Infinity ? 0 : Math.round(metrics.minRenderTime * 100) / 100,
1997
- lastRenderTime: Math.round(metrics.lastRenderTime * 100) / 100,
1998
- pendingRenders: Math.max(0, metrics.pendingRenders),
1999
- totalEvents: metrics.totalEvents,
2000
- cacheHits: metrics.cacheHits,
2001
- cacheMisses: metrics.cacheMisses
2002
- }
2003
- })
2004
- }
2005
-
2006
- self.resetStats = (): void => {
2007
- resetMetrics()
2008
- postMessage({ target: 'resetStats', success: true })
2009
- }
2010
-
2011
- self.getEventCount = (): void => {
2012
- const count = akariSubHandle ? requireApi().getEventCount(akariSubHandle) : 0
2013
- postMessage({ target: 'getEventCount', count })
2014
- }
2015
-
2016
- self.getStyleCount = (): void => {
2017
- const count = akariSubHandle ? requireApi().getStyleCount(akariSubHandle) : 0
2018
- postMessage({ target: 'getStyleCount', count })
2019
- }
2020
-
2021
- // =============================================================================
2022
- // Message Handler
2023
- // =============================================================================
2024
-
2025
- const RENDER_SAFE_TARGETS = new Set([
2026
- 'demand',
2027
- 'video',
2028
- 'getEvents',
2029
- 'getStyles',
2030
- 'getStats',
2031
- 'resetStats',
2032
- 'getEventCount',
2033
- 'getStyleCount',
2034
- 'getColorSpace'
2035
- ])
2036
-
2037
- onmessage = ({ data }: MessageEvent): void => {
2038
- if (!self[data.target]) {
2039
- throw new Error('Unknown event target ' + data.target)
2040
- }
2041
-
2042
- if (!RENDER_SAFE_TARGETS.has(data.target)) {
2043
- invalidateEmptyWindow()
2044
- }
2045
-
2046
- Promise.resolve(self[data.target](data)).catch((error) => {
2047
- postMessage({
2048
- target: 'error',
2049
- error: error instanceof Error ? error.message : String(error)
2050
- })
2051
- })
2052
- }