akarisub 0.2.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +198 -167
  2. package/THIRD_PARTY_NOTICES.md +55 -163
  3. package/dist/COPYRIGHT +56 -164
  4. package/dist/index.d.ts +12 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +11 -147
  7. package/dist/index.js.map +1 -0
  8. package/dist/ts/{ts/akarisub.d.ts → akarisub.d.ts} +80 -99
  9. package/dist/ts/akarisub.d.ts.map +1 -0
  10. package/dist/ts/akarisub.js +2086 -0
  11. package/dist/ts/akarisub.js.map +1 -0
  12. package/dist/ts/timing.d.ts +63 -0
  13. package/dist/ts/timing.d.ts.map +1 -0
  14. package/dist/ts/timing.js +219 -0
  15. package/dist/ts/timing.js.map +1 -0
  16. package/dist/ts/{ts/types.d.ts → types.d.ts} +103 -78
  17. package/dist/ts/types.d.ts.map +1 -0
  18. package/dist/ts/types.js +2 -0
  19. package/dist/ts/{ts/types.js.map → types.js.map} +1 -1
  20. package/dist/ts/{ts/utils.d.ts → utils.d.ts} +0 -31
  21. package/dist/ts/utils.d.ts.map +1 -0
  22. package/dist/ts/{ts/utils.js → utils.js} +0 -56
  23. package/dist/ts/utils.js.map +1 -0
  24. package/dist/ts/wasm.d.ts +12 -0
  25. package/dist/ts/wasm.d.ts.map +1 -0
  26. package/dist/ts/wasm.js +46 -0
  27. package/dist/ts/wasm.js.map +1 -0
  28. package/dist/ts/{ts/webgl2-renderer.d.ts → webgl2-renderer.d.ts} +0 -12
  29. package/dist/ts/webgl2-renderer.d.ts.map +1 -0
  30. package/dist/ts/{ts/webgl2-renderer.js → webgl2-renderer.js} +0 -27
  31. package/dist/ts/webgl2-renderer.js.map +1 -0
  32. package/dist/ts/{ts/webgpu-renderer.d.ts → webgpu-renderer.d.ts} +17 -16
  33. package/dist/ts/webgpu-renderer.d.ts.map +1 -0
  34. package/dist/ts/{ts/webgpu-renderer.js → webgpu-renderer.js} +148 -71
  35. package/dist/ts/webgpu-renderer.js.map +1 -0
  36. package/dist/ts/worker.d.ts +2 -0
  37. package/dist/ts/{ts/worker.d.ts.map → worker.d.ts.map} +1 -1
  38. package/dist/ts/{ts/worker.js → worker.js} +1064 -264
  39. package/dist/ts/worker.js.map +1 -0
  40. package/dist/wrapper.d.ts +8 -0
  41. package/dist/wrapper.d.ts.map +1 -0
  42. package/dist/{ts/index.js → wrapper.js} +4 -14
  43. package/dist/wrapper.js.map +1 -0
  44. package/package.json +24 -22
  45. package/pkg/akarisub.js +53 -0
  46. package/{dist/akarisub-worker.wasm → pkg/akarisub.wasm} +0 -0
  47. package/src/wrapper.ts +46 -0
  48. package/dist/akarisub-worker.js +0 -39
  49. package/dist/akarisub.umd.js +0 -159
  50. package/dist/ts/index.d.ts +0 -14
  51. package/dist/ts/index.d.ts.map +0 -1
  52. package/dist/ts/index.js.map +0 -1
  53. package/dist/ts/ts/akarisub.d.ts.map +0 -1
  54. package/dist/ts/ts/akarisub.js +0 -1073
  55. package/dist/ts/ts/akarisub.js.map +0 -1
  56. package/dist/ts/ts/types.d.ts.map +0 -1
  57. package/dist/ts/ts/types.js +0 -5
  58. package/dist/ts/ts/utils.d.ts.map +0 -1
  59. package/dist/ts/ts/utils.js.map +0 -1
  60. package/dist/ts/ts/webgl2-renderer.d.ts.map +0 -1
  61. package/dist/ts/ts/webgl2-renderer.js.map +0 -1
  62. package/dist/ts/ts/webgpu-renderer.d.ts.map +0 -1
  63. package/dist/ts/ts/webgpu-renderer.js.map +0 -1
  64. package/dist/ts/ts/worker.d.ts +0 -6
  65. package/dist/ts/ts/worker.js.map +0 -1
  66. package/dist/ts/wrapper.d.ts +0 -8
  67. package/dist/ts/wrapper.d.ts.map +0 -1
  68. package/dist/ts/wrapper.js +0 -9
  69. package/dist/ts/wrapper.js.map +0 -1
  70. package/src/ts/akarisub.ts +0 -1272
  71. package/src/ts/types.ts +0 -397
  72. package/src/ts/utils.ts +0 -512
  73. package/src/ts/webgl2-renderer.ts +0 -426
  74. package/src/ts/webgpu-renderer.ts +0 -691
  75. package/src/ts/worker.ts +0 -2052
  76. /package/{dist → assets}/default.woff2 +0 -0
@@ -1,12 +1,11 @@
1
- /**
2
- * AkariSub Worker - TypeScript implementation.
3
- * Runs in a Web Worker to offload subtitle rendering from the main thread.
4
- */
5
1
  /// <reference lib="webworker" />
6
- // @ts-ignore - WASM module is aliased during build
7
- import WASM from 'wasm';
8
2
  import { parseAss, dropBlur, fixPlayRes, libassYCbCrMap } from './utils';
3
+ import { glueUrlFromWasmUrl } from './wasm';
4
+ import { compensatedMediaTime, isStalePresentation, updateTimingCompensation } from './timing';
9
5
  let lastCurrentTime = 0;
6
+ let lastRenderedRequestTime = Number.NaN;
7
+ let lastRenderedRequestWidth = 0;
8
+ let lastRenderedRequestHeight = 0;
10
9
  let rate = 1;
11
10
  let rafId = null;
12
11
  let nextIsRaf = false;
@@ -14,21 +13,29 @@ const nowMs = () => (typeof performance !== 'undefined' ? performance.now() : Da
14
13
  let lastCurrentTimeReceivedAt = nowMs();
15
14
  let targetFps = 24;
16
15
  let onDemandRenderMode = false;
16
+ let frameTimelineMode = false;
17
+ let adaptiveTiming = true;
18
+ let configuredRenderAheadSeconds = 0;
19
+ let fallbackTimingCompensationSeconds = 0;
20
+ let rawAssImageGpuEnabled = false;
17
21
  let useLocalFonts = false;
22
+ let useFontconfigProvider = true;
18
23
  let blendMode = 'wasm';
19
24
  let availableFonts = {};
20
25
  const fontMap_ = {};
21
26
  let attachedFontId = 0; // For attached/preloaded fonts (higher priority)
22
27
  let fallbackFontId = 0; // For fallback fonts (lower priority)
23
- const pendingFallbackFonts = [];
28
+ const MAX_FONT_BYTES = 32 * 1024 * 1024;
29
+ const FONT_FETCH_TIMEOUT_MS = 30_000;
30
+ const pendingFontFamilies = new Set();
24
31
  let debug = false;
25
32
  let clampPos = false;
26
33
  let renderInFlight = false;
34
+ let latestPresentationId = 0;
27
35
  const MAX_QUEUED_RENDERS = 3;
28
36
  const queuedRenders = [];
29
37
  self.width = 0;
30
38
  self.height = 0;
31
- // Performance metrics
32
39
  const metrics = {
33
40
  framesRendered: 0,
34
41
  framesDropped: 0,
@@ -41,7 +48,9 @@ const metrics = {
41
48
  totalEvents: 0,
42
49
  currentEventIndex: 0,
43
50
  cacheHits: 0,
44
- cacheMisses: 0
51
+ cacheMisses: 0,
52
+ lastImageCount: 0,
53
+ lastImagePixels: 0
45
54
  };
46
55
  const resetMetrics = () => {
47
56
  metrics.framesRendered = 0;
@@ -50,6 +59,8 @@ const resetMetrics = () => {
50
59
  metrics.maxRenderTime = 0;
51
60
  metrics.minRenderTime = Infinity;
52
61
  metrics.lastRenderTime = 0;
62
+ metrics.lastImageCount = 0;
63
+ metrics.lastImagePixels = 0;
53
64
  metrics.cacheHits = 0;
54
65
  metrics.cacheMisses = 0;
55
66
  };
@@ -58,19 +69,25 @@ let asyncRenderOptions = true;
58
69
  let offCanvas = null;
59
70
  let offCanvasCtx = null;
60
71
  let offscreenRender = false;
72
+ let rawAssWebGL2Renderer = null;
61
73
  let bufferCanvas = null;
62
74
  let bufferCtx = null;
75
+ let preparedCanvas = null;
76
+ let preparedCtx = null;
77
+ let preparedBufferCanvas = null;
78
+ let preparedBufferCtx = null;
63
79
  let akariSubHandle = 0;
64
80
  let subtitleColorSpace = null;
65
81
  let dropAllBlur = false;
66
82
  let fullTrackWarmupEnabled = false;
67
83
  let hasBitmapBug = false;
68
84
  let _Module = null;
69
- let forceNextDemandRender = false;
85
+ let forceNextRender = false;
86
+ let trackGeneration = 0;
87
+ let fullTrackWarmupGeneration = 0;
70
88
  const TEXT_ENCODER = new TextEncoder();
71
89
  const TEXT_DECODER = new TextDecoder();
72
90
  let akariSubApi = null;
73
- // Pre-allocated object pool for render results
74
91
  const MAX_POOLED_IMAGES = 128;
75
92
  const RENDER_COLLECT_MAX_IMAGES = Math.max(MAX_POOLED_IMAGES, 4096);
76
93
  const PREWARM_MAX_IMAGES = RENDER_COLLECT_MAX_IMAGES;
@@ -84,10 +101,12 @@ const FULL_WARMUP_YIELD_EVERY = 24;
84
101
  const ASS_TIME_SCALE = 1000;
85
102
  const imagePool = [];
86
103
  let poolInitialized = false;
87
- // Batch render-collect buffer: 3 header ints (changed, count, time) + 5 ints per image (x, y, w, h, image_ptr)
104
+ // Batch render-collect buffer: 3 header ints (changed, count, time) + metadata per image.
105
+ // RGBA paths use 5 ints per image (x, y, w, h, image_ptr); raw ASS_Image
106
+ // GPU path uses 8 ints (dst_x, dst_y, w, h, bitmap_ptr, color, stride, type).
88
107
  const RRC_HEADER_INTS = 3;
89
108
  const RRC_IMG_STRIDE = 5;
90
- // Pre-allocated buffer for batch render-collect calls
109
+ const RAW_RRC_IMG_STRIDE = 8;
91
110
  let rrcBufPtr = 0;
92
111
  let rrcBufCapacity = 0;
93
112
  // Cached views over the render-collect buffer; refreshed when the wasm memory
@@ -98,6 +117,7 @@ let rrcViewsBuffer = null;
98
117
  let rrcViewsPtr = 0;
99
118
  let rrcViewsCapacity = 0;
100
119
  const frameImages = [];
120
+ const frameRawAssImages = [];
101
121
  const frameArrayBuffers = [];
102
122
  const frameBitmapPromises = [];
103
123
  let warmupTimer = null;
@@ -106,8 +126,365 @@ let warmupEndTime = 0;
106
126
  let warmupEnabled = false;
107
127
  let firstTrackEventStartTime = null;
108
128
  let fullTrackWarmupPromise = null;
129
+ let fullTrackWarmupStarted = false;
130
+ let blockingFullTrackWarmup = false;
131
+ let fullTrackWarmupStepSeconds = FULL_WARMUP_STEP_SECONDS;
109
132
  let protectedTrackContent = false;
110
133
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
134
+ const RAW_ASS_MAX_TEXTURE_ARRAY_LAYERS = 128;
135
+ const RAW_ASS_INSTANCE_FLOATS = 12;
136
+ const RAW_ASS_VERTEX_SHADER = `#version 300 es
137
+ precision highp float;
138
+
139
+ in vec4 a_destRect;
140
+ in vec4 a_texInfo;
141
+ in vec4 a_color;
142
+
143
+ uniform vec2 u_resolution;
144
+
145
+ out vec2 v_destXY;
146
+ flat out int v_texIndex;
147
+ flat out vec2 v_texSize;
148
+ flat out vec4 v_color;
149
+
150
+ vec2 quadPos(int id) {
151
+ if (id == 0) return vec2(0.0, 0.0);
152
+ if (id == 1) return vec2(1.0, 0.0);
153
+ if (id == 2) return vec2(0.0, 1.0);
154
+ if (id == 3) return vec2(1.0, 0.0);
155
+ if (id == 4) return vec2(1.0, 1.0);
156
+ return vec2(0.0, 1.0);
157
+ }
158
+
159
+ void main() {
160
+ vec2 qp = quadPos(gl_VertexID);
161
+ vec2 pixelPos = a_destRect.xy + qp * a_destRect.zw;
162
+ vec2 clip = (pixelPos / u_resolution) * 2.0 - 1.0;
163
+ clip.y = -clip.y;
164
+ gl_Position = vec4(clip, 0.0, 1.0);
165
+ v_destXY = a_destRect.xy;
166
+ v_texIndex = int(a_texInfo.z);
167
+ v_texSize = a_destRect.zw;
168
+ v_color = a_color;
169
+ }
170
+ `;
171
+ const RAW_ASS_FRAGMENT_SHADER = `#version 300 es
172
+ precision highp float;
173
+ precision highp sampler2DArray;
174
+
175
+ uniform sampler2DArray u_maskArray;
176
+ uniform vec2 u_resolution;
177
+
178
+ in vec2 v_destXY;
179
+ flat in int v_texIndex;
180
+ flat in vec2 v_texSize;
181
+ flat in vec4 v_color;
182
+
183
+ out vec4 fragColor;
184
+
185
+ void main() {
186
+ vec2 fragPos = vec2(gl_FragCoord.x, u_resolution.y - gl_FragCoord.y);
187
+ ivec2 texCoord = ivec2(floor(fragPos - v_destXY));
188
+ ivec2 texSizeI = ivec2(v_texSize);
189
+ if (texCoord.x < 0 || texCoord.y < 0 || texCoord.x >= texSizeI.x || texCoord.y >= texSizeI.y) {
190
+ discard;
191
+ }
192
+ float coverage = texelFetch(u_maskArray, ivec3(texCoord, v_texIndex), 0).r;
193
+ float alpha = coverage * v_color.a;
194
+ fragColor = vec4(v_color.rgb * alpha, alpha);
195
+ }
196
+ `;
197
+ function compileRawAssShader(gl, type, source) {
198
+ const shader = gl.createShader(type);
199
+ if (!shader)
200
+ throw new Error('Failed to create WebGL2 shader');
201
+ gl.shaderSource(shader, source);
202
+ gl.compileShader(shader);
203
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
204
+ const info = gl.getShaderInfoLog(shader);
205
+ gl.deleteShader(shader);
206
+ throw new Error(`Raw ASS WebGL2 shader compilation failed: ${info}`);
207
+ }
208
+ return shader;
209
+ }
210
+ class RawASSImageWebGL2Renderer {
211
+ _canvas = null;
212
+ _gl = null;
213
+ _program = null;
214
+ _vao = null;
215
+ _instanceBuffer = null;
216
+ _maskArray = null;
217
+ _resolutionLoc = null;
218
+ _texWidth = 0;
219
+ _texHeight = 0;
220
+ _texLayers = 0;
221
+ _lastWidth = 0;
222
+ _lastHeight = 0;
223
+ _instanceData = new Float32Array(RAW_ASS_MAX_TEXTURE_ARRAY_LAYERS * RAW_ASS_INSTANCE_FLOATS);
224
+ _maskScratch = new Uint8Array(0);
225
+ init(canvas, width, height) {
226
+ this._canvas = canvas;
227
+ const gl = canvas.getContext('webgl2', {
228
+ alpha: true,
229
+ premultipliedAlpha: true,
230
+ antialias: false,
231
+ depth: false,
232
+ preserveDrawingBuffer: false,
233
+ stencil: false,
234
+ powerPreference: 'high-performance'
235
+ });
236
+ if (!gl)
237
+ return false;
238
+ this._gl = gl;
239
+ const vert = compileRawAssShader(gl, gl.VERTEX_SHADER, RAW_ASS_VERTEX_SHADER);
240
+ const frag = compileRawAssShader(gl, gl.FRAGMENT_SHADER, RAW_ASS_FRAGMENT_SHADER);
241
+ const program = gl.createProgram();
242
+ if (!program)
243
+ throw new Error('Failed to create WebGL2 program');
244
+ gl.attachShader(program, vert);
245
+ gl.attachShader(program, frag);
246
+ gl.linkProgram(program);
247
+ gl.deleteShader(vert);
248
+ gl.deleteShader(frag);
249
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
250
+ throw new Error(`Raw ASS WebGL2 program link failed: ${gl.getProgramInfoLog(program)}`);
251
+ }
252
+ this._program = program;
253
+ this._resolutionLoc = gl.getUniformLocation(program, 'u_resolution');
254
+ this._vao = gl.createVertexArray();
255
+ this._instanceBuffer = gl.createBuffer();
256
+ if (!this._vao || !this._instanceBuffer)
257
+ throw new Error('Failed to create WebGL2 buffers');
258
+ gl.bindVertexArray(this._vao);
259
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._instanceBuffer);
260
+ gl.bufferData(gl.ARRAY_BUFFER, this._instanceData.byteLength, gl.DYNAMIC_DRAW);
261
+ const stride = RAW_ASS_INSTANCE_FLOATS * Float32Array.BYTES_PER_ELEMENT;
262
+ const aDestRect = gl.getAttribLocation(program, 'a_destRect');
263
+ gl.enableVertexAttribArray(aDestRect);
264
+ gl.vertexAttribPointer(aDestRect, 4, gl.FLOAT, false, stride, 0);
265
+ gl.vertexAttribDivisor(aDestRect, 1);
266
+ const aTexInfo = gl.getAttribLocation(program, 'a_texInfo');
267
+ gl.enableVertexAttribArray(aTexInfo);
268
+ gl.vertexAttribPointer(aTexInfo, 4, gl.FLOAT, false, stride, 4 * Float32Array.BYTES_PER_ELEMENT);
269
+ gl.vertexAttribDivisor(aTexInfo, 1);
270
+ const aColor = gl.getAttribLocation(program, 'a_color');
271
+ gl.enableVertexAttribArray(aColor);
272
+ gl.vertexAttribPointer(aColor, 4, gl.FLOAT, false, stride, 8 * Float32Array.BYTES_PER_ELEMENT);
273
+ gl.vertexAttribDivisor(aColor, 1);
274
+ gl.bindVertexArray(null);
275
+ gl.enable(gl.BLEND);
276
+ gl.blendEquation(gl.FUNC_ADD);
277
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
278
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
279
+ this._allocateTextureArray(256, 256, RAW_ASS_MAX_TEXTURE_ARRAY_LAYERS);
280
+ this.updateSize(width, height);
281
+ return true;
282
+ }
283
+ _roundDim(n) {
284
+ return (Math.max(n, 64) + 63) & ~63;
285
+ }
286
+ _roundLayers(n) {
287
+ return Math.min((Math.max(n, 8) + 7) & ~7, RAW_ASS_MAX_TEXTURE_ARRAY_LAYERS);
288
+ }
289
+ _allocateTextureArray(width, height, layers) {
290
+ const gl = this._gl;
291
+ const w = this._roundDim(width);
292
+ const h = this._roundDim(height);
293
+ const l = this._roundLayers(layers);
294
+ if (this._maskArray)
295
+ gl.deleteTexture(this._maskArray);
296
+ this._maskArray = gl.createTexture();
297
+ if (!this._maskArray)
298
+ throw new Error('Failed to create WebGL2 mask texture array');
299
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, this._maskArray);
300
+ gl.texImage3D(gl.TEXTURE_2D_ARRAY, 0, gl.R8, w, h, l, 0, gl.RED, gl.UNSIGNED_BYTE, null);
301
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
302
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
303
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
304
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
305
+ this._texWidth = w;
306
+ this._texHeight = h;
307
+ this._texLayers = l;
308
+ }
309
+ _ensureTextureArray(maxW, maxH, count) {
310
+ const c = Math.min(count, RAW_ASS_MAX_TEXTURE_ARRAY_LAYERS);
311
+ if (maxW <= this._texWidth && maxH <= this._texHeight && c <= this._texLayers)
312
+ return;
313
+ this._allocateTextureArray(Math.max(this._texWidth, maxW), Math.max(this._texHeight, maxH), Math.max(this._texLayers, c));
314
+ }
315
+ updateSize(width, height) {
316
+ if (!this._gl || !this._canvas || width <= 0 || height <= 0)
317
+ return;
318
+ if (this._lastWidth === width && this._lastHeight === height)
319
+ return;
320
+ this._canvas.width = width;
321
+ this._canvas.height = height;
322
+ this._gl.viewport(0, 0, width, height);
323
+ this._lastWidth = width;
324
+ this._lastHeight = height;
325
+ }
326
+ _uploadMaskRegion(w, h, stride, ptr, layer, heap) {
327
+ const gl = this._gl;
328
+ gl.pixelStorei(gl.UNPACK_ROW_LENGTH, stride > 0 ? stride : w);
329
+ gl.texSubImage3D(gl.TEXTURE_2D_ARRAY, 0, 0, 0, layer, w, h, 1, gl.RED, gl.UNSIGNED_BYTE, heap, ptr);
330
+ }
331
+ _uploadMask(img, layer, heap) {
332
+ this._uploadMaskRegion(img.w, img.h, img.stride, img.bitmap, layer, heap);
333
+ }
334
+ renderRawAssImages(images, heap, width, height) {
335
+ if (!this._gl || !this._program || !this._maskArray || !this._instanceBuffer || !this._vao)
336
+ return;
337
+ this.updateSize(width, height);
338
+ let maxW = 0;
339
+ let maxH = 0;
340
+ for (const img of images) {
341
+ if (img.w > maxW)
342
+ maxW = img.w;
343
+ if (img.h > maxH)
344
+ maxH = img.h;
345
+ }
346
+ this._ensureTextureArray(maxW, maxH, Math.min(images.length, RAW_ASS_MAX_TEXTURE_ARRAY_LAYERS));
347
+ const gl = this._gl;
348
+ gl.clearColor(0, 0, 0, 0);
349
+ gl.clear(gl.COLOR_BUFFER_BIT);
350
+ if (!images.length)
351
+ return;
352
+ gl.useProgram(this._program);
353
+ gl.uniform2f(this._resolutionLoc, width, height);
354
+ gl.activeTexture(gl.TEXTURE0);
355
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, this._maskArray);
356
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
357
+ let imageIndex = 0;
358
+ while (imageIndex < images.length) {
359
+ let count = 0;
360
+ while (imageIndex < images.length && count < RAW_ASS_MAX_TEXTURE_ARRAY_LAYERS) {
361
+ const img = images[imageIndex++];
362
+ if (img.w <= 0 || img.h <= 0 || img.bitmap <= 0)
363
+ continue;
364
+ const color = img.color >>> 0;
365
+ const opacity = (255 - (color & 0xff)) / 255;
366
+ if (opacity <= 0)
367
+ continue;
368
+ this._uploadMask(img, count, heap);
369
+ const off = count * RAW_ASS_INSTANCE_FLOATS;
370
+ this._instanceData[off] = img.dst_x;
371
+ this._instanceData[off + 1] = img.dst_y;
372
+ this._instanceData[off + 2] = img.w;
373
+ this._instanceData[off + 3] = img.h;
374
+ this._instanceData[off + 4] = img.w;
375
+ this._instanceData[off + 5] = img.h;
376
+ this._instanceData[off + 6] = count;
377
+ this._instanceData[off + 7] = 0;
378
+ this._instanceData[off + 8] = ((color >>> 24) & 0xff) / 255;
379
+ this._instanceData[off + 9] = ((color >>> 16) & 0xff) / 255;
380
+ this._instanceData[off + 10] = ((color >>> 8) & 0xff) / 255;
381
+ this._instanceData[off + 11] = opacity;
382
+ count++;
383
+ }
384
+ gl.pixelStorei(gl.UNPACK_ROW_LENGTH, 0);
385
+ if (count === 0)
386
+ continue;
387
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._instanceBuffer);
388
+ gl.bufferData(gl.ARRAY_BUFFER, this._instanceData.subarray(0, count * RAW_ASS_INSTANCE_FLOATS), gl.DYNAMIC_DRAW);
389
+ gl.bindVertexArray(this._vao);
390
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, count);
391
+ gl.bindVertexArray(null);
392
+ }
393
+ }
394
+ renderRawAssMetadata(meta, imageCount, heap, width, height) {
395
+ if (!this._gl || !this._program || !this._maskArray || !this._instanceBuffer || !this._vao)
396
+ return 0;
397
+ this.updateSize(width, height);
398
+ let maxW = 0;
399
+ let maxH = 0;
400
+ let validImages = 0;
401
+ for (let i = 0; i < imageCount; i++) {
402
+ const off = i * RAW_RRC_IMG_STRIDE;
403
+ const w = meta[off + 2];
404
+ const h = meta[off + 3];
405
+ const ptr = meta[off + 4];
406
+ if (w <= 0 || h <= 0 || ptr <= 0)
407
+ continue;
408
+ const color = meta[off + 5] >>> 0;
409
+ if (255 - (color & 0xff) <= 0)
410
+ continue;
411
+ if (w > maxW)
412
+ maxW = w;
413
+ if (h > maxH)
414
+ maxH = h;
415
+ validImages++;
416
+ }
417
+ this._ensureTextureArray(maxW, maxH, Math.min(validImages, RAW_ASS_MAX_TEXTURE_ARRAY_LAYERS));
418
+ const gl = this._gl;
419
+ gl.clearColor(0, 0, 0, 0);
420
+ gl.clear(gl.COLOR_BUFFER_BIT);
421
+ if (validImages === 0)
422
+ return 0;
423
+ gl.useProgram(this._program);
424
+ gl.uniform2f(this._resolutionLoc, width, height);
425
+ gl.activeTexture(gl.TEXTURE0);
426
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, this._maskArray);
427
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
428
+ let imageIndex = 0;
429
+ let pixels = 0;
430
+ while (imageIndex < imageCount) {
431
+ let count = 0;
432
+ while (imageIndex < imageCount && count < RAW_ASS_MAX_TEXTURE_ARRAY_LAYERS) {
433
+ const metaOffset = imageIndex++ * RAW_RRC_IMG_STRIDE;
434
+ const w = meta[metaOffset + 2];
435
+ const h = meta[metaOffset + 3];
436
+ const ptr = meta[metaOffset + 4];
437
+ if (w <= 0 || h <= 0 || ptr <= 0)
438
+ continue;
439
+ const color = meta[metaOffset + 5] >>> 0;
440
+ const opacity = (255 - (color & 0xff)) / 255;
441
+ if (opacity <= 0)
442
+ continue;
443
+ const stride = meta[metaOffset + 6];
444
+ this._uploadMaskRegion(w, h, stride, ptr, count, heap);
445
+ const off = count * RAW_ASS_INSTANCE_FLOATS;
446
+ this._instanceData[off] = meta[metaOffset];
447
+ this._instanceData[off + 1] = meta[metaOffset + 1];
448
+ this._instanceData[off + 2] = w;
449
+ this._instanceData[off + 3] = h;
450
+ this._instanceData[off + 4] = w;
451
+ this._instanceData[off + 5] = h;
452
+ this._instanceData[off + 6] = count;
453
+ this._instanceData[off + 7] = 0;
454
+ this._instanceData[off + 8] = ((color >>> 24) & 0xff) / 255;
455
+ this._instanceData[off + 9] = ((color >>> 16) & 0xff) / 255;
456
+ this._instanceData[off + 10] = ((color >>> 8) & 0xff) / 255;
457
+ this._instanceData[off + 11] = opacity;
458
+ pixels += w * h;
459
+ count++;
460
+ }
461
+ gl.pixelStorei(gl.UNPACK_ROW_LENGTH, 0);
462
+ if (count === 0)
463
+ continue;
464
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._instanceBuffer);
465
+ gl.bufferData(gl.ARRAY_BUFFER, this._instanceData.subarray(0, count * RAW_ASS_INSTANCE_FLOATS), gl.DYNAMIC_DRAW);
466
+ gl.bindVertexArray(this._vao);
467
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, count);
468
+ gl.bindVertexArray(null);
469
+ }
470
+ return pixels;
471
+ }
472
+ destroy() {
473
+ const gl = this._gl;
474
+ if (gl) {
475
+ gl.deleteProgram(this._program);
476
+ gl.deleteVertexArray(this._vao);
477
+ gl.deleteBuffer(this._instanceBuffer);
478
+ gl.deleteTexture(this._maskArray);
479
+ }
480
+ this._canvas = null;
481
+ this._gl = null;
482
+ this._program = null;
483
+ this._vao = null;
484
+ this._instanceBuffer = null;
485
+ this._maskArray = null;
486
+ }
487
+ }
111
488
  const initPool = () => {
112
489
  if (poolInitialized)
113
490
  return;
@@ -119,7 +496,6 @@ const initPool = () => {
119
496
  const getPooledItem = (index) => {
120
497
  let item = imagePool[index];
121
498
  if (!item) {
122
- // Grow the pool on demand so frames with many images reuse items too
123
499
  item = { w: 0, h: 0, x: 0, y: 0, image: 0 };
124
500
  imagePool[index] = item;
125
501
  }
@@ -130,10 +506,10 @@ const getPooledItem = (index) => {
130
506
  * Layout: [changed, count, time, (x, y, w, h, image_ptr) * N]
131
507
  * = 3 + 5*N ints
132
508
  */
133
- const ensureRenderCollectBuffer = (maxImages) => {
509
+ const ensureRenderCollectBuffer = (maxImages, imageStride = RRC_IMG_STRIDE) => {
134
510
  if (!_Module || maxImages <= 0)
135
511
  return;
136
- const totalInts = RRC_HEADER_INTS + RRC_IMG_STRIDE * maxImages;
512
+ const totalInts = RRC_HEADER_INTS + imageStride * maxImages;
137
513
  if (rrcBufCapacity >= totalInts && rrcBufPtr)
138
514
  return;
139
515
  const nextCapacity = Math.max(totalInts, (rrcBufCapacity || 64) * 2);
@@ -161,8 +537,8 @@ const refreshRrcViews = () => {
161
537
  rrcViewsPtr = rrcBufPtr;
162
538
  rrcViewsCapacity = rrcBufCapacity;
163
539
  };
164
- const prewarmRenderer = (time) => {
165
- if (!akariSubHandle)
540
+ const prewarmRenderer = (time, generation = trackGeneration) => {
541
+ if (!akariSubHandle || generation !== trackGeneration)
166
542
  return;
167
543
  const api = requireApi();
168
544
  const handle = requireHandle();
@@ -173,6 +549,10 @@ const prewarmRenderer = (time) => {
173
549
  else {
174
550
  api.renderImageCollect(handle, time, 0, rrcBufPtr, rrcBufCapacity);
175
551
  }
552
+ // Prewarming uses the visible libass renderer and can advance libass'
553
+ // internal changed-frame baseline without painting the result. Force the next
554
+ // visible render so a changed=0 response cannot leave stale/blank output.
555
+ markNextRenderForced();
176
556
  };
177
557
  const syncTotalEventsMetric = () => {
178
558
  metrics.totalEvents = akariSubHandle ? requireApi().getEventCount(akariSubHandle) : 0;
@@ -205,6 +585,25 @@ let emptyWindowUntil = -1;
205
585
  const invalidateEmptyWindow = () => {
206
586
  emptyWindowFrom = -1;
207
587
  emptyWindowUntil = -1;
588
+ lastRenderedRequestTime = Number.NaN;
589
+ };
590
+ const markNextRenderForced = () => {
591
+ forceNextRender = true;
592
+ invalidateEmptyWindow();
593
+ };
594
+ const consumeNextRenderForce = () => {
595
+ if (!forceNextRender)
596
+ return 0;
597
+ forceNextRender = false;
598
+ return 1;
599
+ };
600
+ const advanceTrackGeneration = () => {
601
+ trackGeneration++;
602
+ fullTrackWarmupGeneration = trackGeneration;
603
+ fullTrackWarmupPromise = null;
604
+ fullTrackWarmupStarted = false;
605
+ markNextRenderForced();
606
+ return trackGeneration;
208
607
  };
209
608
  const computeEmptyWindow = (time) => {
210
609
  if (!akariSubHandle || !_Module)
@@ -223,28 +622,32 @@ const computeEmptyWindow = (time) => {
223
622
  _Module._free(outPtr);
224
623
  }
225
624
  };
226
- const prewarmEntireTrack = async () => {
227
- if (!akariSubHandle)
625
+ const prewarmEntireTrack = async (generation) => {
626
+ if (!akariSubHandle || generation !== trackGeneration)
228
627
  return;
229
628
  const range = getTrackEventTimeRange();
230
- if (!range)
629
+ if (!range || generation !== trackGeneration)
231
630
  return;
232
631
  const cappedEnd = Math.min(range.end, range.start + FULL_WARMUP_CAP_SECONDS);
233
632
  let ticks = 0;
234
- for (let time = range.start; time <= cappedEnd; time += FULL_WARMUP_STEP_SECONDS) {
235
- if (!akariSubHandle)
633
+ for (let time = range.start; time <= cappedEnd; time += fullTrackWarmupStepSeconds) {
634
+ if (!akariSubHandle || generation !== trackGeneration)
236
635
  return;
237
636
  if (onDemandRenderMode && (renderInFlight || queuedRenders.length > 0 || metrics.pendingRenders > 0)) {
238
637
  await sleep(0);
638
+ if (generation !== trackGeneration)
639
+ return;
239
640
  continue;
240
641
  }
241
- prewarmRenderer(time);
642
+ prewarmRenderer(time, generation);
242
643
  ticks++;
243
644
  if (onDemandRenderMode || ticks % FULL_WARMUP_YIELD_EVERY === 0) {
244
645
  await sleep(0);
646
+ if (generation !== trackGeneration)
647
+ return;
245
648
  }
246
649
  }
247
- prewarmRenderer(cappedEnd);
650
+ prewarmRenderer(cappedEnd, generation);
248
651
  };
249
652
  const getWarmupAnchorTime = (fallbackTime) => {
250
653
  if (firstTrackEventStartTime == null)
@@ -261,20 +664,30 @@ const stopWarmup = () => {
261
664
  }
262
665
  };
263
666
  const scheduleFullTrackWarmup = () => {
264
- if (!fullTrackWarmupEnabled || fullTrackWarmupPromise || !akariSubHandle)
667
+ if (frameTimelineMode ||
668
+ !fullTrackWarmupEnabled ||
669
+ fullTrackWarmupStarted ||
670
+ fullTrackWarmupPromise ||
671
+ !akariSubHandle) {
265
672
  return;
673
+ }
674
+ fullTrackWarmupStarted = true;
675
+ const generation = trackGeneration;
676
+ fullTrackWarmupGeneration = generation;
266
677
  fullTrackWarmupPromise = (async () => {
267
678
  await sleep(0);
679
+ if (generation !== trackGeneration)
680
+ return;
268
681
  try {
269
- await prewarmEntireTrack();
682
+ await prewarmEntireTrack(generation);
270
683
  }
271
684
  catch (e) {
272
685
  if (debug)
273
686
  console.warn('[AkariSub] Full track warmup failed, continuing:', e);
274
687
  }
275
688
  try {
276
- if (akariSubHandle) {
277
- prewarmRenderer(getCurrentTime());
689
+ if (akariSubHandle && generation === trackGeneration) {
690
+ prewarmRenderer(getCurrentTime(), generation);
278
691
  }
279
692
  }
280
693
  catch (e) {
@@ -282,7 +695,9 @@ const scheduleFullTrackWarmup = () => {
282
695
  console.warn('[AkariSub] Post-warmup re-prime failed, continuing:', e);
283
696
  }
284
697
  })().finally(() => {
285
- fullTrackWarmupPromise = null;
698
+ if (fullTrackWarmupGeneration === generation) {
699
+ fullTrackWarmupPromise = null;
700
+ }
286
701
  });
287
702
  };
288
703
  const scheduleWarmupTick = () => {
@@ -494,49 +909,119 @@ const requireHandle = () => {
494
909
  throw new Error('AkariSub instance is not initialized');
495
910
  return akariSubHandle;
496
911
  };
497
- // =============================================================================
498
- // Font Management
499
- // =============================================================================
500
- // Fonts added via addFont are explicitly requested, so they should be attached (high priority)
501
- self.addFont = ({ font }) => asyncWrite(font, false);
912
+ // Fonts added via addFont are explicitly requested, so they should be attached (high priority).
913
+ // A correlated acknowledgement prevents callers from treating rejected bytes as loaded.
914
+ const readBoundedFontUrl = async (url) => {
915
+ const controller = new AbortController();
916
+ const timeout = setTimeout(() => controller.abort(), FONT_FETCH_TIMEOUT_MS);
917
+ try {
918
+ const response = await fetch(url, { signal: controller.signal });
919
+ if (!response.ok)
920
+ throw new Error(`Font request failed with HTTP ${response.status}`);
921
+ const declaredLength = Number(response.headers.get('content-length'));
922
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_FONT_BYTES) {
923
+ await response.body?.cancel();
924
+ throw new Error('Font files are limited to 32 MiB');
925
+ }
926
+ if (!response.body) {
927
+ const bytes = new Uint8Array(await response.arrayBuffer());
928
+ if (bytes.byteLength > MAX_FONT_BYTES)
929
+ throw new Error('Font files are limited to 32 MiB');
930
+ return bytes;
931
+ }
932
+ const reader = response.body.getReader();
933
+ const chunks = [];
934
+ let total = 0;
935
+ while (true) {
936
+ const { done, value } = await reader.read();
937
+ if (done)
938
+ break;
939
+ total += value.byteLength;
940
+ if (total > MAX_FONT_BYTES) {
941
+ await reader.cancel();
942
+ throw new Error('Font files are limited to 32 MiB');
943
+ }
944
+ chunks.push(value);
945
+ }
946
+ const result = new Uint8Array(total);
947
+ let offset = 0;
948
+ for (const chunk of chunks) {
949
+ result.set(chunk, offset);
950
+ offset += chunk.byteLength;
951
+ }
952
+ return result;
953
+ }
954
+ finally {
955
+ clearTimeout(timeout);
956
+ }
957
+ };
958
+ self.addFont = async ({ font, requestId }) => {
959
+ const respond = (bytes) => {
960
+ if (bytes.byteLength > MAX_FONT_BYTES) {
961
+ postMessage({ target: 'addFont', requestId, success: false, error: 'Font files are limited to 32 MiB' });
962
+ return;
963
+ }
964
+ const success = writeFontToFS(bytes, false);
965
+ postMessage({
966
+ target: 'addFont',
967
+ requestId,
968
+ success,
969
+ error: success ? undefined : 'The renderer rejected the font bytes'
970
+ });
971
+ };
972
+ try {
973
+ respond(typeof font === 'string' ? await readBoundedFontUrl(font) : font);
974
+ }
975
+ catch (cause) {
976
+ const error = cause instanceof Error ? cause.message : 'The font could not be read';
977
+ postMessage({ target: 'addFont', requestId, success: false, error });
978
+ }
979
+ };
980
+ self.localFontResult = ({ font, success }) => {
981
+ pendingFontFamilies.delete(font);
982
+ if (success)
983
+ fontMap_[font] = true;
984
+ };
502
985
  const findAvailableFonts = (font) => {
503
986
  font = font.trim().toLowerCase();
504
987
  if (font.startsWith('@'))
505
988
  font = font.substring(1);
506
989
  if (fontMap_[font])
507
990
  return;
508
- fontMap_[font] = true;
509
991
  if (!availableFonts[font]) {
510
- if (useLocalFonts)
992
+ if (useLocalFonts && !pendingFontFamilies.has(font)) {
993
+ pendingFontFamilies.add(font);
511
994
  postMessage({ target: 'getLocalFont', font });
995
+ }
512
996
  }
513
997
  else {
514
- asyncWrite(availableFonts[font]);
515
- }
516
- };
517
- const asyncWrite = (font, isFallback = true) => {
518
- if (typeof font === 'string') {
519
- readAsync(font, (fontData) => {
520
- writeFontToFS(new Uint8Array(fontData), isFallback);
521
- }, console.error);
522
- }
523
- else {
524
- writeFontToFS(font, isFallback);
998
+ if (pendingFontFamilies.has(font))
999
+ return;
1000
+ pendingFontFamilies.add(font);
1001
+ asyncWrite(availableFonts[font], true, (success) => {
1002
+ pendingFontFamilies.delete(font);
1003
+ if (success)
1004
+ fontMap_[font] = true;
1005
+ });
525
1006
  }
526
1007
  };
527
- // Synchronous font loading for critical fonts (fallback fonts)
528
- const syncWrite = (font, isFallback = true) => {
1008
+ const asyncWrite = (font, isFallback = true, settled) => {
529
1009
  if (typeof font === 'string') {
530
- const fontData = read_(font, true);
531
- if (fontData) {
532
- writeFontToFSImmediate(new Uint8Array(fontData), isFallback);
533
- }
1010
+ void readBoundedFontUrl(font)
1011
+ .then((fontData) => {
1012
+ const success = writeFontToFS(fontData, isFallback);
1013
+ settled?.(success);
1014
+ })
1015
+ .catch((error) => {
1016
+ console.error(error);
1017
+ settled?.(false);
1018
+ });
534
1019
  }
535
1020
  else {
536
- writeFontToFSImmediate(font, isFallback);
1021
+ const success = writeFontToFS(font, isFallback);
1022
+ settled?.(success);
537
1023
  }
538
1024
  };
539
- // Debounced font reload
540
1025
  let pendingFontReload = null;
541
1026
  const scheduleReloadFonts = () => {
542
1027
  if (pendingFontReload)
@@ -546,6 +1031,7 @@ const scheduleReloadFonts = () => {
546
1031
  if (akariSubHandle) {
547
1032
  const api = requireApi();
548
1033
  api.reloadFonts(akariSubHandle);
1034
+ markNextRenderForced();
549
1035
  }
550
1036
  }, 16);
551
1037
  };
@@ -557,30 +1043,63 @@ const addFontAsEmbedded = (uint8, name) => {
557
1043
  if (!_Module || !akariSubHandle) {
558
1044
  if (debug)
559
1045
  console.warn('[AkariSub] Cannot add embedded font, module or AkariSub not ready:', name);
560
- return;
1046
+ return false;
1047
+ }
1048
+ if (uint8.byteLength > MAX_FONT_BYTES) {
1049
+ if (debug)
1050
+ console.warn('[AkariSub] Rejected oversized embedded font:', name);
1051
+ return false;
561
1052
  }
562
1053
  try {
563
1054
  const api = requireApi();
564
- // Allocate memory in WASM heap and copy font data
565
1055
  const ptr = _Module._malloc(uint8.length);
566
1056
  if (!ptr) {
567
1057
  console.warn('[AkariSub] Failed to allocate memory for embedded font:', name);
568
- return;
1058
+ return false;
569
1059
  }
570
- // Copy font data to WASM heap
571
1060
  self.HEAPU8.set(uint8, ptr);
572
- withCString(name, (namePtr) => {
573
- api.addFont(akariSubHandle, namePtr, ptr, uint8.length);
574
- });
1061
+ let handedToNative = false;
1062
+ let accepted = false;
1063
+ try {
1064
+ accepted = withCString(name, (namePtr) => {
1065
+ handedToNative = true;
1066
+ return api.addFont(akariSubHandle, namePtr, ptr, uint8.length) === 1;
1067
+ });
1068
+ }
1069
+ finally {
1070
+ if (!handedToNative)
1071
+ _Module._free(ptr);
1072
+ }
1073
+ if (!accepted) {
1074
+ if (debug)
1075
+ console.warn('[AkariSub] Rejected embedded font:', name);
1076
+ return false;
1077
+ }
575
1078
  if (debug)
576
1079
  console.log('[AkariSub] Added embedded font:', name, 'size:', uint8.length);
1080
+ return true;
577
1081
  }
578
1082
  catch (e) {
579
1083
  console.warn('[AkariSub] Failed to add embedded font:', name, e);
1084
+ return false;
580
1085
  }
581
1086
  };
582
1087
  /**
583
- * Write a font to the virtual filesystem so fontconfig can index it.
1088
+ * Admit a font through FreeType/libass before retaining a MEMFS copy.
1089
+ */
1090
+ const admitAndPersistFont = (uint8, fontDir, fontFileName) => {
1091
+ if (!_Module || !akariSubHandle || !addFontAsEmbedded(uint8, fontFileName))
1092
+ return false;
1093
+ try {
1094
+ _Module.FS_createDataFile(fontDir, fontFileName, uint8, true, true, true);
1095
+ }
1096
+ catch (e) {
1097
+ console.warn('Failed to write font to filesystem:', fontDir + '/' + fontFileName, e);
1098
+ }
1099
+ return true;
1100
+ };
1101
+ /**
1102
+ * Write an admitted font to the virtual filesystem so fontconfig can index it.
584
1103
  * Fonts are written to separate directories based on priority:
585
1104
  * - /fonts/attached: For attached/preloaded fonts (highest priority)
586
1105
  * - /fonts/fallback: For fallback fonts
@@ -588,67 +1107,35 @@ const addFontAsEmbedded = (uint8, name) => {
588
1107
  const writeFontToFS = (uint8, isFallback = true) => {
589
1108
  const fontDir = isFallback ? '/fonts/fallback' : '/fonts/attached';
590
1109
  const fontFileName = isFallback ? 'fallback-' + fallbackFontId++ : 'attached-' + attachedFontId++;
591
- if (_Module) {
592
- try {
593
- _Module.FS_createDataFile(fontDir, fontFileName, uint8, true, true, true);
594
- }
595
- catch (e) {
596
- console.warn('Failed to write font to filesystem:', fontDir + '/' + fontFileName, e);
597
- }
598
- if (!isFallback) {
599
- addFontAsEmbedded(uint8, fontFileName);
600
- }
601
- else if (akariSubHandle) {
602
- addFontAsEmbedded(uint8, fontFileName);
603
- }
604
- else {
605
- pendingFallbackFonts.push({ data: uint8, name: fontFileName });
606
- }
607
- }
1110
+ if (!admitAndPersistFont(uint8, fontDir, fontFileName))
1111
+ return false;
608
1112
  scheduleReloadFonts();
1113
+ return true;
609
1114
  };
610
1115
  /**
611
- * Immediate font write without debounced reload (for synchronous loading).
1116
+ * Immediate admitted-font write without debounced reload (for synchronous loading).
612
1117
  */
613
1118
  const writeFontToFSImmediate = (uint8, isFallback = true) => {
614
1119
  const fontDir = isFallback ? '/fonts/fallback' : '/fonts/attached';
615
1120
  const fontFileName = isFallback ? 'fallback-' + fallbackFontId++ : 'attached-' + attachedFontId++;
616
- if (_Module) {
617
- try {
618
- _Module.FS_createDataFile(fontDir, fontFileName, uint8, true, true, true);
619
- if (debug)
620
- console.log('[AkariSub] Wrote font to FS:', fontDir + '/' + fontFileName, 'size:', uint8.length);
621
- }
622
- catch (e) {
623
- console.warn('Failed to write font to filesystem:', fontDir + '/' + fontFileName, e);
624
- }
625
- if (!isFallback) {
626
- addFontAsEmbedded(uint8, fontFileName);
627
- }
628
- else if (akariSubHandle) {
629
- addFontAsEmbedded(uint8, fontFileName);
630
- }
631
- else {
632
- pendingFallbackFonts.push({ data: uint8, name: fontFileName });
633
- }
634
- }
1121
+ const success = admitAndPersistFont(uint8, fontDir, fontFileName);
1122
+ if (success && debug)
1123
+ console.log('[AkariSub] Wrote admitted font to FS:', fontDir + '/' + fontFileName, 'size:', uint8.length);
1124
+ return success;
635
1125
  };
636
1126
  const processAvailableFonts = (content) => {
637
1127
  if (!availableFonts)
638
1128
  return;
639
1129
  const isLargeFile = content.length > 500000;
640
1130
  if (isLargeFile) {
641
- // Extract only the styles section for large files
642
1131
  const stylesMatch = content.match(/\[V4\+?\s*Styles?\][^\[]*(?=\[|$)/i);
643
1132
  if (stylesMatch) {
644
1133
  const stylesSection = stylesMatch[0];
645
- // Parse only the styles section
646
1134
  const styleFontMatches = stylesSection.matchAll(/^Style:[^,]*,([^,]+)/gm);
647
1135
  for (const match of styleFontMatches) {
648
1136
  findAvailableFonts(match[1].trim());
649
1137
  }
650
1138
  }
651
- // For Events section in large files, limit to first 1000 \fn tags
652
1139
  const eventsMatch = content.match(/\[Events\][\s\S]*/i);
653
1140
  if (eventsMatch) {
654
1141
  const eventsContent = eventsMatch[0];
@@ -662,7 +1149,6 @@ const processAvailableFonts = (content) => {
662
1149
  }
663
1150
  }
664
1151
  else {
665
- // Original behavior for small files
666
1152
  const sections = parseAss(content, true);
667
1153
  for (let i = 0; i < sections.length; i++) {
668
1154
  for (let j = 0; j < sections[i].body.length; j++) {
@@ -672,7 +1158,6 @@ const processAvailableFonts = (content) => {
672
1158
  }
673
1159
  }
674
1160
  }
675
- // Use matchAll for Events section
676
1161
  const eventsMatch = content.match(/\[Events\][\s\S]*/i);
677
1162
  if (eventsMatch) {
678
1163
  const eventsContent = eventsMatch[0];
@@ -683,9 +1168,6 @@ const processAvailableFonts = (content) => {
683
1168
  }
684
1169
  }
685
1170
  };
686
- // =============================================================================
687
- // Network Utilities
688
- // =============================================================================
689
1171
  const read_ = (url, ab) => {
690
1172
  const xhr = new XMLHttpRequest();
691
1173
  xhr.open('GET', url, false);
@@ -705,22 +1187,19 @@ const readAsync = (url, load, err) => {
705
1187
  xhr.onerror = err;
706
1188
  xhr.send(null);
707
1189
  };
708
- // =============================================================================
709
- // Track Management
710
- // =============================================================================
711
1190
  const finishTrackLoad = () => {
712
1191
  const api = requireApi();
713
1192
  const handle = requireHandle();
714
1193
  syncTotalEventsMetric();
715
1194
  firstTrackEventStartTime = getFirstEventStartTime();
716
1195
  subtitleColorSpace = libassYCbCrMap[api.getTrackColorSpace(handle)];
717
- forceNextDemandRender = true;
1196
+ markNextRenderForced();
718
1197
  postMessage({ target: 'verifyColorSpace', subtitleColorSpace });
719
1198
  postMessage({ target: 'trackReady' });
720
1199
  };
721
1200
  self.setTrack = ({ content }) => {
722
1201
  stopWarmup();
723
- fullTrackWarmupPromise = null;
1202
+ advanceTrackGeneration();
724
1203
  protectedTrackContent = false;
725
1204
  if (isBinaryContent(content)) {
726
1205
  createTrackFromBytes(toUint8Array(content));
@@ -737,7 +1216,7 @@ self.setTrack = ({ content }) => {
737
1216
  };
738
1217
  self.setEncryptedTrack = async ({ content }) => {
739
1218
  stopWarmup();
740
- fullTrackWarmupPromise = null;
1219
+ advanceTrackGeneration();
741
1220
  protectedTrackContent = true;
742
1221
  const decrypted = await decryptSubtitleContent(content);
743
1222
  try {
@@ -753,21 +1232,35 @@ self.getColorSpace = () => {
753
1232
  };
754
1233
  self.freeTrack = () => {
755
1234
  stopWarmup();
756
- fullTrackWarmupPromise = null;
1235
+ advanceTrackGeneration();
757
1236
  firstTrackEventStartTime = null;
758
1237
  protectedTrackContent = false;
759
1238
  const api = requireApi();
760
1239
  const handle = requireHandle();
761
1240
  api.removeTrack(handle);
762
1241
  syncTotalEventsMetric();
1242
+ markNextRenderForced();
763
1243
  };
764
1244
  self.setTrackByUrl = ({ url }) => {
765
1245
  self.setTrack({ content: read_(url) });
766
1246
  };
767
- // =============================================================================
768
- // Time Management
769
- // =============================================================================
770
1247
  let _isPaused = true;
1248
+ const projectedCurrentTime = () => {
1249
+ if (_isPaused)
1250
+ return lastCurrentTime;
1251
+ return lastCurrentTime + ((nowMs() - lastCurrentTimeReceivedAt) / 1000) * rate;
1252
+ };
1253
+ const freezeClockAtProjectedTime = () => {
1254
+ lastCurrentTime = projectedCurrentTime();
1255
+ lastCurrentTimeReceivedAt = nowMs();
1256
+ };
1257
+ const setPlaybackRate = (newRate) => {
1258
+ if (!Number.isFinite(newRate))
1259
+ return;
1260
+ if (!_isPaused)
1261
+ freezeClockAtProjectedTime();
1262
+ rate = newRate;
1263
+ };
771
1264
  const getCurrentTime = () => {
772
1265
  const diff = (nowMs() - lastCurrentTimeReceivedAt) / 1000;
773
1266
  if (_isPaused) {
@@ -776,18 +1269,27 @@ const getCurrentTime = () => {
776
1269
  else {
777
1270
  if (diff > 5) {
778
1271
  console.error("Didn't receive currentTime > 5 seconds. Assuming video was paused.");
1272
+ freezeClockAtProjectedTime();
779
1273
  setIsPaused(true);
1274
+ return lastCurrentTime;
780
1275
  }
781
1276
  return lastCurrentTime + diff * rate;
782
1277
  }
783
1278
  };
1279
+ const getRenderClockTime = () => compensatedMediaTime(getCurrentTime(), rate, configuredRenderAheadSeconds, adaptiveTiming ? fallbackTimingCompensationSeconds : 0, _isPaused);
784
1280
  const setCurrentTime = (currentTime) => {
1281
+ if (!Number.isFinite(currentTime))
1282
+ return;
785
1283
  lastCurrentTime = currentTime;
786
1284
  lastCurrentTimeReceivedAt = nowMs();
787
1285
  if (onDemandRenderMode) {
788
1286
  return;
789
1287
  }
790
1288
  if (!rafId) {
1289
+ if (_isPaused) {
1290
+ renderLoop();
1291
+ return;
1292
+ }
791
1293
  if (nextIsRaf) {
792
1294
  rafId = requestAnimationFrame(renderLoop);
793
1295
  }
@@ -801,27 +1303,32 @@ const setCurrentTime = (currentTime) => {
801
1303
  }
802
1304
  };
803
1305
  const setIsPaused = (isPaused) => {
1306
+ if (isPaused === _isPaused)
1307
+ return;
1308
+ if (isPaused) {
1309
+ freezeClockAtProjectedTime();
1310
+ }
804
1311
  if (onDemandRenderMode) {
805
1312
  _isPaused = isPaused;
1313
+ if (!isPaused)
1314
+ lastCurrentTimeReceivedAt = nowMs();
806
1315
  if (rafId) {
807
1316
  cancelAnimationFrame(rafId);
808
1317
  rafId = null;
809
1318
  }
810
1319
  return;
811
1320
  }
812
- if (isPaused !== _isPaused) {
813
- _isPaused = isPaused;
814
- if (isPaused) {
815
- if (rafId) {
816
- cancelAnimationFrame(rafId);
817
- rafId = null;
818
- }
819
- }
820
- else {
821
- lastCurrentTimeReceivedAt = nowMs();
822
- rafId = requestAnimationFrame(renderLoop);
1321
+ _isPaused = isPaused;
1322
+ if (isPaused) {
1323
+ if (rafId) {
1324
+ cancelAnimationFrame(rafId);
1325
+ rafId = null;
823
1326
  }
824
1327
  }
1328
+ else {
1329
+ lastCurrentTimeReceivedAt = nowMs();
1330
+ rafId = requestAnimationFrame(renderLoop);
1331
+ }
825
1332
  };
826
1333
  const flushQueuedRender = () => {
827
1334
  if (renderInFlight || queuedRenders.length === 0)
@@ -830,41 +1337,72 @@ const flushQueuedRender = () => {
830
1337
  const dropped = queuedRenders.length - 1;
831
1338
  metrics.framesDropped += dropped;
832
1339
  const latest = queuedRenders[queuedRenders.length - 1];
1340
+ latest.force = queuedRenders.some((item) => item.force) ? 1 : latest.force;
833
1341
  queuedRenders.length = 0;
834
1342
  queuedRenders.push(latest);
835
1343
  }
836
1344
  const next = queuedRenders.shift();
837
1345
  if (!next)
838
1346
  return;
839
- render(next.time, next.force);
1347
+ render(next.time, next.force, next.requestId, next.renderEpoch, next.prepareId, next.presentationId);
840
1348
  };
841
- const completeRenderCycle = () => {
1349
+ const completeRenderCycle = (painted = false) => {
1350
+ if (painted && !onDemandRenderMode && !_isPaused && metrics.renderStartTime > 0 && adaptiveTiming) {
1351
+ fallbackTimingCompensationSeconds = updateTimingCompensation(fallbackTimingCompensationSeconds, performance.now(), metrics.renderStartTime);
1352
+ }
1353
+ metrics.renderStartTime = 0;
842
1354
  renderInFlight = false;
1355
+ const hadQueuedRender = queuedRenders.length > 0;
843
1356
  flushQueuedRender();
1357
+ if (!hadQueuedRender && !renderInFlight)
1358
+ scheduleFullTrackWarmup();
844
1359
  };
845
- const render = (time, force) => {
1360
+ const render = (time, force, requestId, renderEpoch, prepareId, presentationId) => {
846
1361
  if (renderInFlight) {
847
- const queuedItem = { time, force: force ? 1 : 0 };
1362
+ const queuedItem = { time, force: force ? 1 : 0, requestId, renderEpoch, prepareId, presentationId };
848
1363
  if (queuedItem.force) {
849
1364
  queuedRenders.length = 0;
850
1365
  }
851
1366
  else {
852
1367
  const lastQueued = queuedRenders[queuedRenders.length - 1];
853
1368
  if (lastQueued && Math.abs(lastQueued.time - queuedItem.time) > 0.25) {
1369
+ queuedItem.force = queuedRenders.some((item) => item.force) ? 1 : queuedItem.force;
854
1370
  queuedRenders.length = 0;
855
1371
  }
856
1372
  }
857
1373
  if (queuedRenders.length >= MAX_QUEUED_RENDERS) {
858
- queuedRenders.shift();
1374
+ const dropped = queuedRenders.shift();
1375
+ if (dropped?.force)
1376
+ queuedItem.force = 1;
859
1377
  metrics.framesDropped++;
860
1378
  }
861
1379
  queuedRenders.push(queuedItem);
862
1380
  return;
863
1381
  }
1382
+ // Repeated manual renders at the exact same media time and canvas size are
1383
+ // deterministic. Skip the WASM call and reuse the existing canvas contents;
1384
+ // this matches players/benchmark harnesses that repaint paused frames.
1385
+ if (!force &&
1386
+ time === lastRenderedRequestTime &&
1387
+ self.width === lastRenderedRequestWidth &&
1388
+ self.height === lastRenderedRequestHeight) {
1389
+ metrics.cacheHits++;
1390
+ if (prepareId == null)
1391
+ postMessage({ target: 'unbusy', requestId, renderEpoch });
1392
+ else
1393
+ postPreparedSnapshot(prepareId, renderEpoch, time);
1394
+ return;
1395
+ }
1396
+ lastRenderedRequestTime = time;
1397
+ lastRenderedRequestWidth = self.width;
1398
+ lastRenderedRequestHeight = self.height;
864
1399
  // Inside a known-empty window the output cannot change: skip the WASM call
865
1400
  if (!force && emptyWindowFrom >= 0 && time >= emptyWindowFrom && time < emptyWindowUntil) {
866
1401
  metrics.cacheHits++;
867
- postMessage({ target: 'unbusy' });
1402
+ if (prepareId == null)
1403
+ postMessage({ target: 'unbusy', requestId, renderEpoch });
1404
+ else
1405
+ postPreparedSnapshot(prepareId, renderEpoch, time);
868
1406
  return;
869
1407
  }
870
1408
  renderInFlight = true;
@@ -876,11 +1414,14 @@ const render = (time, force) => {
876
1414
  const api = requireApi();
877
1415
  const handle = requireHandle();
878
1416
  const forceInt = force ? 1 : 0;
879
- // Use the batch render-collect API: single WASM call does render + metadata + image data extraction.
880
- ensureRenderCollectBuffer(RENDER_COLLECT_MAX_IMAGES);
881
- const written = blendMode === 'wasm'
882
- ? api.renderBlendCollect(handle, time, forceInt, rrcBufPtr, rrcBufCapacity)
883
- : api.renderImageCollect(handle, time, forceInt, rrcBufPtr, rrcBufCapacity);
1417
+ const useRawAssImagePath = prepareId == null && !!rawAssWebGL2Renderer && offscreenRender === true;
1418
+ const imageStride = useRawAssImagePath ? RAW_RRC_IMG_STRIDE : RRC_IMG_STRIDE;
1419
+ ensureRenderCollectBuffer(RENDER_COLLECT_MAX_IMAGES, imageStride);
1420
+ const written = useRawAssImagePath
1421
+ ? api.renderRawCollect(handle, time, forceInt, rrcBufPtr, rrcBufCapacity)
1422
+ : blendMode === 'wasm'
1423
+ ? api.renderBlendCollect(handle, time, forceInt, rrcBufPtr, rrcBufCapacity)
1424
+ : api.renderImageCollect(handle, time, forceInt, rrcBufPtr, rrcBufCapacity);
884
1425
  // Refresh after the WASM call: rendering may have grown the memory
885
1426
  refreshRrcViews();
886
1427
  const headerView = rrcHeaderView;
@@ -889,7 +1430,6 @@ const render = (time, force) => {
889
1430
  if (headerView[1] === 0 && (emptyWindowFrom < 0 || time < emptyWindowFrom || time >= emptyWindowUntil)) {
890
1431
  computeEmptyWindow(time);
891
1432
  }
892
- // Update metrics
893
1433
  const renderEndTime = performance.now();
894
1434
  const renderDuration = renderEndTime - renderStartTime;
895
1435
  metrics.lastRenderTime = renderDuration;
@@ -903,6 +1443,8 @@ const render = (time, force) => {
903
1443
  metrics.cacheMisses++;
904
1444
  }
905
1445
  else {
1446
+ metrics.lastImageCount = 0;
1447
+ metrics.lastImagePixels = 0;
906
1448
  metrics.cacheHits++;
907
1449
  }
908
1450
  if (debug) {
@@ -917,10 +1459,21 @@ const render = (time, force) => {
917
1459
  const buffers = frameArrayBuffers;
918
1460
  images.length = 0;
919
1461
  buffers.length = 0;
920
- if (written === 0)
921
- return paintImages({ images, buffers, times });
922
1462
  const meta = rrcMetaView;
923
- const useAsyncBitmapPath = asyncRender && offscreenRender !== true;
1463
+ metrics.lastImageCount = written;
1464
+ metrics.lastImagePixels = 0;
1465
+ const stride = useRawAssImagePath ? RAW_RRC_IMG_STRIDE : RRC_IMG_STRIDE;
1466
+ for (let i = 0; i < written; ++i) {
1467
+ const metaOffset = i * stride;
1468
+ metrics.lastImagePixels += Math.max(0, meta[metaOffset + 2]) * Math.max(0, meta[metaOffset + 3]);
1469
+ }
1470
+ if (useRawAssImagePath) {
1471
+ return paintRawAssImages({ meta, count: written, times, requestId, renderEpoch, presentationId });
1472
+ }
1473
+ if (written === 0) {
1474
+ return paintImages({ images, buffers, times, requestId, renderEpoch, prepareId, time, presentationId });
1475
+ }
1476
+ const useAsyncBitmapPath = asyncRender;
924
1477
  if (useAsyncBitmapPath) {
925
1478
  const promises = frameBitmapPromises;
926
1479
  promises.length = written;
@@ -946,25 +1499,32 @@ const render = (time, force) => {
946
1499
  : createImageBitmap(imageData);
947
1500
  images[i] = item;
948
1501
  }
949
- Promise.all(promises).then((bitmaps) => {
1502
+ Promise.all(promises)
1503
+ .then((bitmaps) => {
950
1504
  for (let i = 0; i < written; i++) {
951
1505
  images[i].image = bitmaps[i];
952
1506
  }
953
1507
  if (debug)
954
1508
  times.JSBitmapGenerationTime = Date.now() - (times.JSRenderTime || 0);
955
- paintImages({ images, buffers: bitmaps, times });
956
- }).catch(() => {
1509
+ paintImages({ images, buffers: bitmaps, times, requestId, renderEpoch, prepareId, time, presentationId });
1510
+ })
1511
+ .catch(() => {
957
1512
  if (asyncRenderOptions) {
958
1513
  asyncRenderOptions = false;
959
1514
  console.warn('[AkariSub] createImageBitmap options not supported, disabling');
960
1515
  metrics.pendingRenders--;
961
1516
  completeRenderCycle();
962
- render(time, force);
1517
+ render(time, force, requestId, renderEpoch, prepareId, presentationId);
963
1518
  }
964
1519
  else {
965
1520
  metrics.pendingRenders--;
966
- postMessage({ target: 'unbusy' });
967
- completeRenderCycle();
1521
+ if (prepareId == null) {
1522
+ postMessage({ target: 'unbusy', requestId, renderEpoch });
1523
+ completeRenderCycle();
1524
+ }
1525
+ else {
1526
+ void postPreparedSnapshot(prepareId, renderEpoch, time).finally(() => completeRenderCycle());
1527
+ }
968
1528
  }
969
1529
  });
970
1530
  }
@@ -1000,31 +1560,156 @@ const render = (time, force) => {
1000
1560
  }
1001
1561
  images[i] = item;
1002
1562
  }
1003
- paintImages({ images, buffers, times });
1563
+ paintImages({ images, buffers, times, requestId, renderEpoch, prepareId, time, presentationId });
1004
1564
  }
1005
1565
  }
1006
1566
  else {
1007
1567
  metrics.pendingRenders--;
1008
- postMessage({ target: 'unbusy' });
1009
- completeRenderCycle();
1568
+ if (prepareId == null) {
1569
+ postMessage({ target: 'unbusy', requestId, renderEpoch });
1570
+ completeRenderCycle();
1571
+ }
1572
+ else {
1573
+ void postPreparedSnapshot(prepareId, renderEpoch, time).finally(() => completeRenderCycle());
1574
+ }
1010
1575
  }
1011
1576
  };
1012
- self.demand = ({ time }) => {
1577
+ self.demand = ({ time, force: demandForce, requestId, renderEpoch, presentationId }) => {
1013
1578
  lastCurrentTime = time;
1014
1579
  lastCurrentTimeReceivedAt = nowMs();
1015
- const force = forceNextDemandRender ? 1 : 0;
1016
- forceNextDemandRender = false;
1017
- render(time, force);
1580
+ if (presentationId != null)
1581
+ latestPresentationId = Math.max(latestPresentationId, presentationId);
1582
+ const force = demandForce ? 1 : consumeNextRenderForce();
1583
+ render(time, force, requestId, renderEpoch, undefined, presentationId);
1584
+ };
1585
+ self.prepare = ({ time, prepareId, renderEpoch, force }) => {
1586
+ render(time, force ? 1 : 0, undefined, renderEpoch, prepareId);
1018
1587
  };
1019
1588
  const renderLoop = (force) => {
1020
1589
  rafId = null;
1021
- render(getCurrentTime(), force);
1590
+ render(getRenderClockTime(), force || consumeNextRenderForce());
1022
1591
  if (!_isPaused) {
1023
1592
  rafId = requestAnimationFrame(renderLoop);
1024
1593
  }
1025
1594
  };
1026
- const paintImages = ({ times, images, buffers }) => {
1595
+ const paintRawAssImages = ({ times, meta, count, requestId, renderEpoch, presentationId }) => {
1027
1596
  metrics.pendingRenders--;
1597
+ if (isStalePresentation(presentationId, latestPresentationId)) {
1598
+ postMessage({ target: 'unbusy', requestId, renderEpoch, presentationId, painted: false });
1599
+ completeRenderCycle();
1600
+ return;
1601
+ }
1602
+ const width = self.width;
1603
+ const height = self.height;
1604
+ const renderStart = performance.now();
1605
+ let painted = true;
1606
+ try {
1607
+ const pixels = rawAssWebGL2Renderer.renderRawAssMetadata(meta, count, self.HEAPU8, width, height);
1608
+ if (debug) {
1609
+ times.JSRenderTime = performance.now() - renderStart;
1610
+ times.bitmaps = count;
1611
+ times.rawPixels = pixels;
1612
+ let total = 0;
1613
+ for (const key in times) {
1614
+ if (key !== 'bitmaps' && key !== 'rawPixels')
1615
+ total += times[key] || 0;
1616
+ }
1617
+ console.log(`[WEBGL2-RAW-ASS] Bitmaps: ${count} Pixels: ${pixels} Total: ${total | 0}ms`, times);
1618
+ }
1619
+ }
1620
+ catch (error) {
1621
+ painted = false;
1622
+ console.error('[AkariSub] Raw ASS_Image WebGL2 render failed:', error);
1623
+ }
1624
+ postMessage({ target: 'unbusy', requestId, renderEpoch, presentationId, painted });
1625
+ completeRenderCycle(painted);
1626
+ };
1627
+ const ensurePreparedCanvas = () => {
1628
+ const width = self.width;
1629
+ const height = self.height;
1630
+ if (!preparedCanvas)
1631
+ preparedCanvas = new OffscreenCanvas(width, height);
1632
+ if (preparedCanvas.width !== width || preparedCanvas.height !== height) {
1633
+ preparedCanvas.width = width;
1634
+ preparedCanvas.height = height;
1635
+ preparedCtx = null;
1636
+ }
1637
+ preparedCtx ??= preparedCanvas.getContext('2d');
1638
+ if (!preparedCtx)
1639
+ throw new Error('Prepared-frame canvas is unavailable');
1640
+ return preparedCtx;
1641
+ };
1642
+ const postPreparedSnapshot = async (prepareId, renderEpoch, time) => {
1643
+ try {
1644
+ const context = ensurePreparedCanvas();
1645
+ const bitmap = context.canvas.transferToImageBitmap();
1646
+ postMessage({ target: 'preparedFrame', prepareId, renderEpoch, time, width: self.width, height: self.height, bitmap }, [bitmap]);
1647
+ }
1648
+ catch (error) {
1649
+ if (debug)
1650
+ console.warn('[AkariSub] Failed to copy prepared exact frame:', error);
1651
+ postMessage({ target: 'preparedFrame', prepareId, renderEpoch, time });
1652
+ }
1653
+ };
1654
+ const paintPreparedImages = (images, prepareId, renderEpoch, time) => {
1655
+ const width = self.width;
1656
+ const height = self.height;
1657
+ try {
1658
+ preparedCtx = ensurePreparedCanvas();
1659
+ preparedCtx.clearRect(0, 0, width, height);
1660
+ for (const image of images) {
1661
+ if (!image.image)
1662
+ continue;
1663
+ if (image.image instanceof ImageBitmap) {
1664
+ preparedCtx.drawImage(image.image, image.x, image.y);
1665
+ image.image.close();
1666
+ continue;
1667
+ }
1668
+ if (!preparedBufferCanvas)
1669
+ preparedBufferCanvas = new OffscreenCanvas(image.w, image.h);
1670
+ if (preparedBufferCanvas.width !== image.w || preparedBufferCanvas.height !== image.h) {
1671
+ preparedBufferCanvas.width = image.w;
1672
+ preparedBufferCanvas.height = image.h;
1673
+ preparedBufferCtx = null;
1674
+ }
1675
+ preparedBufferCtx ??= preparedBufferCanvas.getContext('2d');
1676
+ if (!preparedBufferCtx)
1677
+ throw new Error('Prepared-frame buffer canvas is unavailable');
1678
+ const byteLength = image.w * image.h * 4;
1679
+ const pixels = image.image instanceof Uint8ClampedArray
1680
+ ? image.image
1681
+ : self.HEAPU8C.subarray(image.image, image.image + byteLength);
1682
+ preparedBufferCtx.putImageData(new ImageData(pixels, image.w, image.h), 0, 0);
1683
+ preparedCtx.drawImage(preparedBufferCanvas, image.x, image.y);
1684
+ }
1685
+ void postPreparedSnapshot(prepareId, renderEpoch, time).finally(() => completeRenderCycle());
1686
+ }
1687
+ catch (error) {
1688
+ for (const image of images) {
1689
+ if (image.image instanceof ImageBitmap)
1690
+ image.image.close();
1691
+ }
1692
+ if (debug)
1693
+ console.warn('[AkariSub] Failed to prepare exact frame:', error);
1694
+ postMessage({ target: 'preparedFrame', prepareId, renderEpoch, time, unchanged: true });
1695
+ completeRenderCycle();
1696
+ }
1697
+ };
1698
+ const paintImages = ({ times, images, buffers, requestId, renderEpoch, prepareId, time, presentationId }) => {
1699
+ metrics.pendingRenders--;
1700
+ if (prepareId != null) {
1701
+ paintPreparedImages(images, prepareId, renderEpoch, time ?? 0);
1702
+ return;
1703
+ }
1704
+ if (isStalePresentation(presentationId, latestPresentationId)) {
1705
+ for (const image of images) {
1706
+ if (image.image instanceof ImageBitmap)
1707
+ image.image.close();
1708
+ }
1709
+ postMessage({ target: 'unbusy', requestId, renderEpoch, presentationId, painted: false });
1710
+ completeRenderCycle();
1711
+ return;
1712
+ }
1028
1713
  const width = self.width;
1029
1714
  const height = self.height;
1030
1715
  const imageCount = images.length;
@@ -1035,17 +1720,23 @@ const paintImages = ({ times, images, buffers }) => {
1035
1720
  times,
1036
1721
  width,
1037
1722
  height,
1038
- colorSpace: subtitleColorSpace
1723
+ colorSpace: subtitleColorSpace,
1724
+ requestId,
1725
+ renderEpoch,
1726
+ presentationId
1039
1727
  };
1040
1728
  if (offscreenRender) {
1041
- // Only resize canvas when dimensions actually change
1042
1729
  if (offCanvas.height !== height || offCanvas.width !== width) {
1043
1730
  offCanvas.width = width;
1044
1731
  offCanvas.height = height;
1045
1732
  }
1046
1733
  offCanvasCtx.clearRect(0, 0, width, height);
1047
- if (asyncRender) {
1048
- // Batch draw all images
1734
+ const firstImage = imageCount > 0 ? images[0].image : null;
1735
+ const hasImageBitmapInputs = typeof ImageBitmap !== 'undefined' && firstImage instanceof ImageBitmap;
1736
+ if (hasImageBitmapInputs) {
1737
+ // Batch draw all ImageBitmaps. This is much faster than rebuilding ImageData
1738
+ // from WASM-backed RGBA pointers and works for both plain offscreenRender=true
1739
+ // and hybrid offscreen rendering.
1049
1740
  for (let i = 0; i < imageCount; i++) {
1050
1741
  const img = images[i];
1051
1742
  if (img.image) {
@@ -1055,13 +1746,11 @@ const paintImages = ({ times, images, buffers }) => {
1055
1746
  }
1056
1747
  }
1057
1748
  else {
1058
- // Non-async path with buffer canvas
1059
1749
  for (let i = 0; i < imageCount; i++) {
1060
1750
  const img = images[i];
1061
1751
  if (img.image) {
1062
1752
  const imgW = img.w;
1063
1753
  const imgH = img.h;
1064
- // Only resize buffer canvas when needed
1065
1754
  if (bufferCanvas.width !== imgW || bufferCanvas.height !== imgH) {
1066
1755
  bufferCanvas.width = imgW;
1067
1756
  bufferCanvas.height = imgH;
@@ -1077,7 +1766,7 @@ const paintImages = ({ times, images, buffers }) => {
1077
1766
  if (offscreenRender === 'hybrid') {
1078
1767
  if (!imageCount) {
1079
1768
  postMessage(resultObject);
1080
- completeRenderCycle();
1769
+ completeRenderCycle(true);
1081
1770
  return;
1082
1771
  }
1083
1772
  if (debug)
@@ -1090,10 +1779,10 @@ const paintImages = ({ times, images, buffers }) => {
1090
1779
  asyncRender: true
1091
1780
  };
1092
1781
  postMessage(result, [bitmap]);
1093
- completeRenderCycle();
1782
+ completeRenderCycle(true);
1094
1783
  }
1095
1784
  catch {
1096
- postMessage({ target: 'unbusy' });
1785
+ postMessage({ target: 'unbusy', requestId, renderEpoch });
1097
1786
  completeRenderCycle();
1098
1787
  }
1099
1788
  }
@@ -1105,41 +1794,54 @@ const paintImages = ({ times, images, buffers }) => {
1105
1794
  total += times[key] || 0;
1106
1795
  console.log('Bitmaps: ' + imageCount + ' Total: ' + (total | 0) + 'ms', times);
1107
1796
  }
1108
- postMessage({ target: 'unbusy' });
1109
- completeRenderCycle();
1797
+ postMessage({ target: 'unbusy', requestId, renderEpoch, painted: true });
1798
+ completeRenderCycle(true);
1110
1799
  }
1111
1800
  }
1112
1801
  else {
1113
1802
  postMessage(resultObject, buffers);
1114
- completeRenderCycle();
1803
+ completeRenderCycle(true);
1115
1804
  }
1116
1805
  };
1117
- // Custom requestAnimationFrame for worker
1118
- const requestAnimationFrame = self.requestAnimationFrame ? self.requestAnimationFrame.bind(self) : (() => {
1119
- let nextRAF = 0;
1120
- return (func) => {
1121
- const now = nowMs();
1122
- if (nextRAF === 0) {
1123
- nextRAF = now + 1000 / targetFps;
1124
- }
1125
- else {
1126
- while (now + 2 >= nextRAF) {
1127
- nextRAF += 1000 / targetFps;
1806
+ const requestAnimationFrame = self.requestAnimationFrame
1807
+ ? self.requestAnimationFrame.bind(self)
1808
+ : (() => {
1809
+ let nextRAF = 0;
1810
+ return (func) => {
1811
+ const now = nowMs();
1812
+ if (nextRAF === 0) {
1813
+ nextRAF = now + 1000 / targetFps;
1128
1814
  }
1129
- }
1130
- const delay = Math.max(nextRAF - now, 0);
1131
- return setTimeout(func, delay);
1132
- };
1133
- })();
1815
+ else {
1816
+ while (now + 2 >= nextRAF) {
1817
+ nextRAF += 1000 / targetFps;
1818
+ }
1819
+ }
1820
+ const delay = Math.max(nextRAF - now, 0);
1821
+ return setTimeout(func, delay);
1822
+ };
1823
+ })();
1134
1824
  const cancelAnimationFrame = self.cancelAnimationFrame ? self.cancelAnimationFrame.bind(self) : clearTimeout;
1135
- // =============================================================================
1136
- // WASM Initialization
1137
- // =============================================================================
1138
1825
  self.init = async (data) => {
1139
1826
  hasBitmapBug = data.hasBitmapBug;
1140
1827
  fullTrackWarmupEnabled = !!data.fullTrackWarmup;
1828
+ frameTimelineMode = !!data.frameTimelineMode;
1829
+ adaptiveTiming = data.adaptiveTiming ?? true;
1830
+ configuredRenderAheadSeconds = Number.isFinite(data.renderAhead) ? data.renderAhead : 0;
1831
+ _isPaused = data.initialIsPaused ?? true;
1832
+ if (typeof data.initialPlaybackRate === 'number' && Number.isFinite(data.initialPlaybackRate)) {
1833
+ rate = data.initialPlaybackRate;
1834
+ }
1141
1835
  if (typeof data.initialTime === 'number' && Number.isFinite(data.initialTime)) {
1142
1836
  lastCurrentTime = data.initialTime;
1837
+ if (!_isPaused &&
1838
+ typeof data.initialTimeSnapshotAtMs === 'number' &&
1839
+ Number.isFinite(data.initialTimeSnapshotAtMs)) {
1840
+ lastCurrentTimeReceivedAt = nowMs() - Math.max(0, Date.now() - data.initialTimeSnapshotAtMs);
1841
+ }
1842
+ else {
1843
+ lastCurrentTimeReceivedAt = nowMs();
1844
+ }
1143
1845
  }
1144
1846
  const _fetch = self.fetch;
1145
1847
  const setWasmUrl = (wasmUrl) => {
@@ -1150,11 +1852,20 @@ self.init = async (data) => {
1150
1852
  const restoreFetch = () => {
1151
1853
  self.fetch = _fetch;
1152
1854
  };
1153
- const loadWasm = (wasmUrl) => {
1855
+ const loadWasm = async (wasmUrl, glueUrl) => {
1856
+ const jsGlueUrl = glueUrl || glueUrlFromWasmUrl(wasmUrl);
1857
+ const glue = (await import(/* @vite-ignore */ jsGlueUrl));
1858
+ const WASM = glue.default;
1154
1859
  setWasmUrl(wasmUrl);
1155
- return WASM({
1156
- wasm: !WebAssembly.instantiateStreaming ? read_(wasmUrl, true) : undefined
1157
- }).finally(restoreFetch);
1860
+ try {
1861
+ return await WASM({
1862
+ locateFile: (path) => (path.endsWith('.wasm') ? wasmUrl : path),
1863
+ wasm: !WebAssembly.instantiateStreaming ? read_(wasmUrl, true) : undefined
1864
+ });
1865
+ }
1866
+ finally {
1867
+ restoreFetch();
1868
+ }
1158
1869
  };
1159
1870
  const onWasmLoaded = async (Module) => {
1160
1871
  _Module = Module; // Store module reference for FS access
@@ -1162,6 +1873,7 @@ self.init = async (data) => {
1162
1873
  create: Module._akarisub_create,
1163
1874
  destroy: Module._akarisub_destroy,
1164
1875
  setDropAnimations: Module._akarisub_set_drop_animations,
1876
+ setAdaptiveBlendLayouts: Module._akarisub_set_adaptive_blend_layouts,
1165
1877
  createTrackMem: Module._akarisub_create_track_mem,
1166
1878
  removeTrack: Module._akarisub_remove_track,
1167
1879
  resizeCanvas: Module._akarisub_resize_canvas,
@@ -1169,6 +1881,7 @@ self.init = async (data) => {
1169
1881
  reloadFonts: Module._akarisub_reload_fonts,
1170
1882
  setDefaultFont: Module._akarisub_set_default_font,
1171
1883
  setFallbackFonts: Module._akarisub_set_fallback_fonts,
1884
+ setUseFontconfigProvider: Module._akarisub_set_use_fontconfig_provider,
1172
1885
  setMemoryLimits: Module._akarisub_set_memory_limits,
1173
1886
  getEventCount: Module._akarisub_get_event_count,
1174
1887
  allocEvent: Module._akarisub_alloc_event,
@@ -1190,9 +1903,9 @@ self.init = async (data) => {
1190
1903
  getEventTimeRange: Module._akarisub_get_event_time_range,
1191
1904
  getEmptyWindow: Module._akarisub_get_empty_window,
1192
1905
  renderBlendCollect: Module._akarisub_render_blend_collect,
1193
- renderImageCollect: Module._akarisub_render_image_collect
1906
+ renderImageCollect: Module._akarisub_render_image_collect,
1907
+ renderRawCollect: Module._akarisub_render_raw_collect
1194
1908
  };
1195
- // Normalize fallback fonts and deduplicate
1196
1909
  const fallbackFonts = [];
1197
1910
  const fallbackFontKeys = new Set();
1198
1911
  if (data.fallbackFonts && data.fallbackFonts.length > 0) {
@@ -1262,6 +1975,7 @@ self.init = async (data) => {
1262
1975
  self.width = data.width;
1263
1976
  self.height = data.height;
1264
1977
  onDemandRenderMode = !!data.onDemandRender;
1978
+ rawAssImageGpuEnabled = !!data.rawAssImageGpu;
1265
1979
  blendMode = data.blendMode;
1266
1980
  asyncRender = data.asyncRender;
1267
1981
  if (asyncRender && typeof createImageBitmap === 'undefined') {
@@ -1274,8 +1988,7 @@ self.init = async (data) => {
1274
1988
  const testCtx = testCanvas.getContext('2d');
1275
1989
  if (testCtx) {
1276
1990
  const testData = testCtx.getImageData(0, 0, 1, 1);
1277
- await createImageBitmap(testData, { premultiplyAlpha: 'none', colorSpaceConversion: 'none' })
1278
- .catch(() => {
1991
+ await createImageBitmap(testData, { premultiplyAlpha: 'none', colorSpaceConversion: 'none' }).catch(() => {
1279
1992
  asyncRenderOptions = false;
1280
1993
  console.warn('[AkariSub] createImageBitmap options not supported (Safari?), rendering without options');
1281
1994
  });
@@ -1289,6 +2002,7 @@ self.init = async (data) => {
1289
2002
  debug = data.debug;
1290
2003
  targetFps = data.targetFps || targetFps;
1291
2004
  useLocalFonts = data.useLocalFonts;
2005
+ useFontconfigProvider = data.useFontconfigProvider;
1292
2006
  dropAllBlur = data.dropAllBlur;
1293
2007
  clampPos = data.clampPos;
1294
2008
  // Load fallback fonts asynchronously to avoid blocking worker thread
@@ -1301,29 +2015,26 @@ self.init = async (data) => {
1301
2015
  if (availableFonts && availableFonts[fontKey]) {
1302
2016
  const fontUrl = availableFonts[fontKey];
1303
2017
  if (typeof fontUrl === 'string') {
1304
- // Async fetch for URL-based fonts
1305
- const promise = new Promise((resolve) => {
1306
- readAsync(fontUrl, (fontData) => {
1307
- writeFontToFSImmediate(new Uint8Array(fontData), true);
2018
+ const promise = readBoundedFontUrl(fontUrl)
2019
+ .then((fontData) => {
2020
+ const accepted = writeFontToFSImmediate(fontData, true);
2021
+ if (accepted) {
1308
2022
  fontMap_[fontKey] = true;
1309
2023
  if (debug)
1310
2024
  console.log('[AkariSub] Loaded fallback font async:', fontKey);
1311
- resolve();
1312
- }, (e) => {
1313
- console.error('Failed to load fallback font:', fontKey, e);
1314
- resolve(); // Don't fail initialization if a single font fails
1315
- });
2025
+ }
2026
+ })
2027
+ .catch((error) => {
2028
+ console.error('Failed to load fallback font:', fontKey, error);
1316
2029
  });
1317
2030
  fontPromises.push(promise);
1318
2031
  }
1319
2032
  else {
1320
- // Font data directly provided - synchronous write is OK here
1321
- writeFontToFSImmediate(fontUrl, true);
1322
- fontMap_[fontKey] = true;
2033
+ if (writeFontToFSImmediate(fontUrl, true))
2034
+ fontMap_[fontKey] = true;
1323
2035
  }
1324
2036
  }
1325
2037
  }
1326
- // Wait for all fonts to load (with 30s timeout to prevent blocking forever)
1327
2038
  if (fontPromises.length > 0) {
1328
2039
  let timeoutId = null;
1329
2040
  let timedOut = false;
@@ -1346,18 +2057,13 @@ self.init = async (data) => {
1346
2057
  }
1347
2058
  }
1348
2059
  };
1349
- await loadFallbackFontsAsync();
1350
2060
  const primaryFallback = fallbackFonts.length > 0 ? fallbackFonts[0] : null;
1351
2061
  akariSubHandle = withCString(primaryFallback || '', (fontPtr) => {
1352
2062
  return requireApi().create(self.width, self.height, fontPtr, debug ? 1 : 0);
1353
2063
  });
1354
- if (pendingFallbackFonts.length > 0) {
1355
- for (const { data: fontData, name: fontName } of pendingFallbackFonts) {
1356
- addFontAsEmbedded(fontData, fontName);
1357
- }
1358
- pendingFallbackFonts.length = 0;
1359
- requireApi().reloadFonts(akariSubHandle);
1360
- }
2064
+ // Admission requires a live libass handle. Load fallback bytes only after
2065
+ // creation so rejected files are never retained in MEMFS or fontMap_.
2066
+ await loadFallbackFontsAsync();
1361
2067
  if (fallbackFonts.length > 0) {
1362
2068
  withCString(fallbackFonts.join(','), (fontsPtr) => {
1363
2069
  requireApi().setFallbackFonts(requireHandle(), fontsPtr);
@@ -1375,11 +2081,7 @@ self.init = async (data) => {
1375
2081
  if (!subContent)
1376
2082
  subContent = read_(data.subUrl);
1377
2083
  }
1378
- // For large files, emit partial_ready early to allow playback to start
1379
- // while font loading and track parsing continues in the background
1380
- const isLargeSubtitle = typeof subContent === 'string'
1381
- ? subContent.length > 500000
1382
- : toUint8Array(subContent).byteLength > 500000;
2084
+ const isLargeSubtitle = typeof subContent === 'string' ? subContent.length > 500000 : toUint8Array(subContent).byteLength > 500000;
1383
2085
  if (isLargeSubtitle) {
1384
2086
  postMessage({ target: 'partial_ready' });
1385
2087
  if (debug)
@@ -1400,23 +2102,23 @@ self.init = async (data) => {
1400
2102
  const attachedFontPromises = [];
1401
2103
  for (const font of data.fonts || []) {
1402
2104
  if (typeof font === 'string') {
1403
- const promise = new Promise((resolve) => {
1404
- readAsync(font, (fontData) => {
1405
- writeFontToFSImmediate(new Uint8Array(fontData), false);
2105
+ const promise = readBoundedFontUrl(font)
2106
+ .then((fontData) => {
2107
+ const accepted = writeFontToFSImmediate(fontData, false);
2108
+ if (accepted) {
1406
2109
  hasAttachedFonts = true;
1407
2110
  if (debug)
1408
2111
  console.log('[AkariSub] Loaded attached font async:', font);
1409
- resolve();
1410
- }, (e) => {
1411
- console.error('Failed to load attached font:', font, e);
1412
- resolve();
1413
- });
2112
+ }
2113
+ })
2114
+ .catch((error) => {
2115
+ console.error('Failed to load attached font:', font, error);
1414
2116
  });
1415
2117
  attachedFontPromises.push(promise);
1416
2118
  }
1417
2119
  else {
1418
- writeFontToFSImmediate(font, false);
1419
- hasAttachedFonts = true;
2120
+ if (writeFontToFSImmediate(font, false))
2121
+ hasAttachedFonts = true;
1420
2122
  }
1421
2123
  }
1422
2124
  if (attachedFontPromises.length > 0) {
@@ -1447,6 +2149,8 @@ self.init = async (data) => {
1447
2149
  if (debug)
1448
2150
  console.log('[AkariSub] Font reload complete');
1449
2151
  }
2152
+ // Enable fontconfig only after startup fonts are in MEMFS (see AkariSub ctor).
2153
+ requireApi().setUseFontconfigProvider(requireHandle(), useFontconfigProvider ? 1 : 0);
1450
2154
  if (typeof subContent === 'string') {
1451
2155
  createTrackFromString(subContent);
1452
2156
  }
@@ -1462,45 +2166,118 @@ self.init = async (data) => {
1462
2166
  firstTrackEventStartTime = getFirstEventStartTime();
1463
2167
  subtitleColorSpace = libassYCbCrMap[requireApi().getTrackColorSpace(requireHandle())];
1464
2168
  requireApi().setDropAnimations(requireHandle(), data.dropAllAnimations || 0);
2169
+ requireApi().setAdaptiveBlendLayouts(requireHandle(), data.adaptiveBlendLayouts ? 1 : 0);
2170
+ blockingFullTrackWarmup = data.blockingFullTrackWarmup;
2171
+ fullTrackWarmupStepSeconds = data.fullTrackWarmupStep > 0 ? data.fullTrackWarmupStep : FULL_WARMUP_STEP_SECONDS;
1465
2172
  if (data.libassMemoryLimit > 0 || data.libassGlyphLimit > 0) {
1466
2173
  requireApi().setMemoryLimits(requireHandle(), data.libassGlyphLimit || 0, data.libassMemoryLimit || 0);
1467
2174
  }
1468
2175
  initPool();
1469
2176
  ensureRenderCollectBuffer(PREWARM_MAX_IMAGES);
1470
2177
  try {
1471
- prewarmRenderer(lastCurrentTime);
2178
+ prewarmRenderer(getCurrentTime());
1472
2179
  }
1473
2180
  catch (e) {
1474
2181
  if (debug)
1475
2182
  console.warn('[AkariSub] Prewarm render failed, continuing:', e);
1476
2183
  }
1477
- forceNextDemandRender = true;
2184
+ if (blockingFullTrackWarmup && fullTrackWarmupEnabled && !fullTrackWarmupStarted) {
2185
+ fullTrackWarmupStarted = true;
2186
+ const generation = trackGeneration;
2187
+ fullTrackWarmupGeneration = generation;
2188
+ try {
2189
+ await prewarmEntireTrack(generation);
2190
+ }
2191
+ catch (e) {
2192
+ if (debug)
2193
+ console.warn('[AkariSub] Full track warmup failed, continuing:', e);
2194
+ }
2195
+ try {
2196
+ prewarmRenderer(getCurrentTime(), generation);
2197
+ }
2198
+ catch (e) {
2199
+ if (debug)
2200
+ console.warn('[AkariSub] Post-warmup re-prime failed, continuing:', e);
2201
+ }
2202
+ }
2203
+ markNextRenderForced();
1478
2204
  postMessage({ target: 'ready' });
1479
2205
  postMessage({ target: 'verifyColorSpace', subtitleColorSpace });
1480
- scheduleFullTrackWarmup();
1481
2206
  };
1482
- loadWasm(data.wasmUrl).then(onWasmLoaded).catch((e) => {
2207
+ loadWasm(data.wasmUrl, data.glueUrl)
2208
+ .then(onWasmLoaded)
2209
+ .catch((e) => {
1483
2210
  console.error('[AkariSub] WASM loading failed:', e);
1484
2211
  postMessage({ target: 'error', error: 'WASM loading failed: ' + (e && e.message ? e.message : String(e)) });
1485
2212
  });
1486
2213
  };
1487
- // =============================================================================
1488
- // Canvas Management
1489
- // =============================================================================
1490
- self.offscreenCanvas = ({ transferable }) => {
2214
+ self.offscreenCanvas = ({ transferable, rawAssImageGpu }) => {
1491
2215
  offCanvas = transferable[0];
1492
- offCanvasCtx = offCanvas.getContext('2d', { desynchronized: true });
1493
- if (!asyncRender) {
1494
- bufferCanvas = new OffscreenCanvas(self.width, self.height);
1495
- bufferCtx = bufferCanvas.getContext('2d', { desynchronized: true });
2216
+ rawAssWebGL2Renderer = null;
2217
+ const useRawAssImageGpu = rawAssImageGpu ?? rawAssImageGpuEnabled;
2218
+ if (useRawAssImageGpu) {
2219
+ try {
2220
+ const renderer = new RawASSImageWebGL2Renderer();
2221
+ if (renderer.init(offCanvas, self.width, self.height)) {
2222
+ rawAssWebGL2Renderer = renderer;
2223
+ offCanvasCtx = null;
2224
+ bufferCanvas = null;
2225
+ bufferCtx = null;
2226
+ offscreenRender = true;
2227
+ if (debug)
2228
+ console.log('[AkariSub] Using worker WebGL2 raw ASS_Image renderer');
2229
+ return;
2230
+ }
2231
+ }
2232
+ catch (error) {
2233
+ rawAssWebGL2Renderer = null;
2234
+ if (debug)
2235
+ console.warn('[AkariSub] Worker WebGL2 raw ASS_Image renderer unavailable:', error);
2236
+ }
1496
2237
  }
2238
+ offCanvasCtx = offCanvas.getContext('2d');
2239
+ // Plain offscreen rendering draws raw WASM image pointers through a reusable
2240
+ // buffer canvas. Create it regardless of asyncRender because the render path
2241
+ // deliberately disables per-image ImageBitmap creation for offscreenRender=true.
2242
+ bufferCanvas = new OffscreenCanvas(self.width, self.height);
2243
+ bufferCtx = bufferCanvas.getContext('2d');
1497
2244
  offscreenRender = true;
1498
2245
  };
1499
2246
  self.detachOffscreen = () => {
2247
+ rawAssWebGL2Renderer?.destroy();
2248
+ rawAssWebGL2Renderer = null;
1500
2249
  offCanvas = new OffscreenCanvas(self.width, self.height);
1501
- offCanvasCtx = offCanvas.getContext('2d', { desynchronized: true });
2250
+ offCanvasCtx = offCanvas.getContext('2d');
1502
2251
  offscreenRender = 'hybrid';
1503
2252
  };
2253
+ self.presentFrame = ({ bitmap, presentationId, renderEpoch, frameIndex }) => {
2254
+ let painted = false;
2255
+ try {
2256
+ if (isStalePresentation(presentationId, latestPresentationId))
2257
+ return;
2258
+ latestPresentationId = Math.max(latestPresentationId, presentationId);
2259
+ if (!offCanvasCtx || !offCanvas)
2260
+ return;
2261
+ offCanvasCtx.clearRect(0, 0, offCanvas.width, offCanvas.height);
2262
+ offCanvasCtx.drawImage(bitmap, 0, 0);
2263
+ painted = true;
2264
+ }
2265
+ finally {
2266
+ bitmap.close();
2267
+ if (painted)
2268
+ postMessage({ target: 'presentedFrame', presentationId, renderEpoch, frameIndex });
2269
+ }
2270
+ };
2271
+ self.presentation = ({ presentationId }) => {
2272
+ if (Number.isSafeInteger(presentationId)) {
2273
+ latestPresentationId = Math.max(latestPresentationId, presentationId);
2274
+ }
2275
+ };
2276
+ self.frameTimelineMode = ({ enabled }) => {
2277
+ frameTimelineMode = enabled;
2278
+ if (enabled)
2279
+ stopWarmup();
2280
+ };
1504
2281
  self.canvas = ({ width, height, videoWidth, videoHeight, force }) => {
1505
2282
  if (width == null)
1506
2283
  throw new Error('Invalid canvas size specified');
@@ -1509,20 +2286,28 @@ self.canvas = ({ width, height, videoWidth, videoHeight, force }) => {
1509
2286
  if (akariSubHandle)
1510
2287
  requireApi().resizeCanvas(akariSubHandle, width, height, videoWidth, videoHeight);
1511
2288
  if (force)
1512
- render(lastCurrentTime, true);
2289
+ render(getRenderClockTime(), true);
1513
2290
  };
1514
- self.video = ({ currentTime, isPaused, rate: newRate }) => {
2291
+ self.video = ({ currentTime, isPaused, rate: newRate, renderAhead }) => {
2292
+ if (renderAhead != null && Number.isFinite(renderAhead))
2293
+ configuredRenderAheadSeconds = renderAhead;
1515
2294
  if (currentTime != null)
1516
2295
  setCurrentTime(currentTime);
1517
2296
  if (isPaused != null)
1518
2297
  setIsPaused(isPaused);
1519
2298
  if (newRate != null)
1520
- rate = newRate;
2299
+ setPlaybackRate(newRate);
1521
2300
  };
1522
2301
  self.destroy = () => {
1523
2302
  stopWarmup();
1524
- fullTrackWarmupPromise = null;
2303
+ advanceTrackGeneration();
1525
2304
  firstTrackEventStartTime = null;
2305
+ rawAssWebGL2Renderer?.destroy();
2306
+ rawAssWebGL2Renderer = null;
2307
+ preparedCanvas = null;
2308
+ preparedCtx = null;
2309
+ preparedBufferCanvas = null;
2310
+ preparedBufferCtx = null;
1526
2311
  if (_Module) {
1527
2312
  if (rrcBufPtr) {
1528
2313
  _Module._free(rrcBufPtr);
@@ -1544,9 +2329,6 @@ self.destroy = () => {
1544
2329
  self.setAsyncRender = ({ value }) => {
1545
2330
  asyncRender = value && typeof createImageBitmap !== 'undefined';
1546
2331
  };
1547
- // =============================================================================
1548
- // Event Management
1549
- // =============================================================================
1550
2332
  const applyEventFields = (index, event) => {
1551
2333
  const api = requireApi();
1552
2334
  const handle = requireHandle();
@@ -1637,6 +2419,7 @@ self.createEvent = ({ event }) => {
1637
2419
  if (index >= 0)
1638
2420
  applyEventFields(index, event);
1639
2421
  syncTotalEventsMetric();
2422
+ markNextRenderForced();
1640
2423
  };
1641
2424
  self.getEvents = () => {
1642
2425
  const events = [];
@@ -1655,18 +2438,18 @@ self.getEvents = () => {
1655
2438
  };
1656
2439
  self.setEvent = ({ event, index }) => {
1657
2440
  applyEventFields(index, event);
2441
+ markNextRenderForced();
1658
2442
  };
1659
2443
  self.removeEvent = ({ index }) => {
1660
2444
  requireApi().removeEvent(requireHandle(), index);
1661
2445
  syncTotalEventsMetric();
2446
+ markNextRenderForced();
1662
2447
  };
1663
- // =============================================================================
1664
- // Style Management
1665
- // =============================================================================
1666
2448
  self.createStyle = ({ style }) => {
1667
2449
  const index = requireApi().allocStyle(requireHandle());
1668
2450
  if (index >= 0)
1669
2451
  applyStyleFields(index, style);
2452
+ markNextRenderForced();
1670
2453
  return index;
1671
2454
  };
1672
2455
  self.getStyles = () => {
@@ -1680,27 +2463,29 @@ self.getStyles = () => {
1680
2463
  };
1681
2464
  self.setStyle = ({ style, index }) => {
1682
2465
  applyStyleFields(index, style);
2466
+ markNextRenderForced();
1683
2467
  };
1684
2468
  self.removeStyle = ({ index }) => {
1685
2469
  requireApi().removeStyle(requireHandle(), index);
2470
+ markNextRenderForced();
1686
2471
  };
1687
2472
  self.styleOverride = (data) => {
1688
2473
  const index = self.createStyle(data);
1689
2474
  if (typeof index === 'number' && index >= 0) {
1690
2475
  requireApi().styleOverrideIndex(requireHandle(), index);
1691
2476
  }
2477
+ markNextRenderForced();
1692
2478
  };
1693
2479
  self.disableStyleOverride = () => {
1694
2480
  requireApi().disableStyleOverride(requireHandle());
2481
+ markNextRenderForced();
1695
2482
  };
1696
2483
  self.defaultFont = ({ font }) => {
1697
2484
  withCString(font, (fontPtr) => {
1698
2485
  requireApi().setDefaultFont(requireHandle(), fontPtr);
1699
2486
  });
2487
+ markNextRenderForced();
1700
2488
  };
1701
- // =============================================================================
1702
- // Performance Metrics
1703
- // =============================================================================
1704
2489
  self.getStats = () => {
1705
2490
  const avgRenderTime = metrics.framesRendered > 0 ? metrics.totalRenderTime / metrics.framesRendered : 0;
1706
2491
  postMessage({
@@ -1712,8 +2497,22 @@ self.getStats = () => {
1712
2497
  maxRenderTime: Math.round(metrics.maxRenderTime * 100) / 100,
1713
2498
  minRenderTime: metrics.minRenderTime === Infinity ? 0 : Math.round(metrics.minRenderTime * 100) / 100,
1714
2499
  lastRenderTime: Math.round(metrics.lastRenderTime * 100) / 100,
2500
+ timingCompensationMs: Math.round(fallbackTimingCompensationSeconds * 100_000) / 100,
2501
+ lastImageCount: metrics.lastImageCount,
2502
+ lastImagePixels: metrics.lastImagePixels,
1715
2503
  pendingRenders: Math.max(0, metrics.pendingRenders),
1716
2504
  totalEvents: metrics.totalEvents,
2505
+ usingWorker: true,
2506
+ offscreenRender: !!offscreenRender,
2507
+ rawAssImageGpu: !!rawAssWebGL2Renderer,
2508
+ workerRenderer: rawAssWebGL2Renderer
2509
+ ? 'webgl2-raw-ass'
2510
+ : offscreenRender === 'hybrid'
2511
+ ? 'hybrid'
2512
+ : offscreenRender
2513
+ ? 'canvas2d'
2514
+ : 'main-thread',
2515
+ onDemandRender: onDemandRenderMode,
1717
2516
  cacheHits: metrics.cacheHits,
1718
2517
  cacheMisses: metrics.cacheMisses
1719
2518
  }
@@ -1731,11 +2530,12 @@ self.getStyleCount = () => {
1731
2530
  const count = akariSubHandle ? requireApi().getStyleCount(akariSubHandle) : 0;
1732
2531
  postMessage({ target: 'getStyleCount', count });
1733
2532
  };
1734
- // =============================================================================
1735
- // Message Handler
1736
- // =============================================================================
1737
2533
  const RENDER_SAFE_TARGETS = new Set([
1738
2534
  'demand',
2535
+ 'prepare',
2536
+ 'presentation',
2537
+ 'presentFrame',
2538
+ 'frameTimelineMode',
1739
2539
  'video',
1740
2540
  'getEvents',
1741
2541
  'getStyles',