akarisub 0.2.0 → 0.2.1

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