akarisub 0.1.0

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