@solidtv/renderer 1.5.4 → 1.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/exports/canvas-shaders.d.ts +1 -0
- package/dist/exports/canvas-shaders.js +1 -0
- package/dist/exports/canvas-shaders.js.map +1 -1
- package/dist/exports/index.d.ts +1 -0
- package/dist/exports/index.js +1 -0
- package/dist/exports/index.js.map +1 -1
- package/dist/exports/webgl-shaders.d.ts +1 -0
- package/dist/exports/webgl-shaders.js +1 -0
- package/dist/exports/webgl-shaders.js.map +1 -1
- package/dist/src/core/CoreShaderManager.js +16 -2
- package/dist/src/core/CoreShaderManager.js.map +1 -1
- package/dist/src/core/lib/WebGlContextWrapper.d.ts +13 -0
- package/dist/src/core/lib/WebGlContextWrapper.js +15 -0
- package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
- package/dist/src/core/lib/validateImageBitmap.js +7 -0
- package/dist/src/core/lib/validateImageBitmap.js.map +1 -1
- package/dist/src/core/platforms/web/WebPlatform.js +57 -30
- package/dist/src/core/platforms/web/WebPlatform.js.map +1 -1
- package/dist/src/core/renderers/canvas/CanvasRenderer.d.ts +10 -0
- package/dist/src/core/renderers/canvas/CanvasRenderer.js +19 -0
- package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlRenderer.js +14 -1
- package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
- package/dist/src/core/shaders/canvas/EdgeFade.d.ts +10 -0
- package/dist/src/core/shaders/canvas/EdgeFade.js +92 -0
- package/dist/src/core/shaders/canvas/EdgeFade.js.map +1 -0
- package/dist/src/core/shaders/canvas/LinearGradient.js +1 -18
- package/dist/src/core/shaders/canvas/LinearGradient.js.map +1 -1
- package/dist/src/core/shaders/templates/EdgeFadeTemplate.d.ts +32 -0
- package/dist/src/core/shaders/templates/EdgeFadeTemplate.js +9 -0
- package/dist/src/core/shaders/templates/EdgeFadeTemplate.js.map +1 -0
- package/dist/src/core/shaders/webgl/EdgeFade.d.ts +9 -0
- package/dist/src/core/shaders/webgl/EdgeFade.js +74 -0
- package/dist/src/core/shaders/webgl/EdgeFade.js.map +1 -0
- package/dist/src/core/shaders/webgl/LinearGradient.js +31 -51
- package/dist/src/core/shaders/webgl/LinearGradient.js.map +1 -1
- package/dist/src/core/shaders/webgl/RadialGradient.js +2 -2
- package/dist/src/core/shaders/webgl/RadialProgress.js +7 -0
- package/dist/src/core/shaders/webgl/RadialProgress.js.map +1 -1
- package/dist/src/core/text-rendering/SdfFontHandler.d.ts +1 -0
- package/dist/src/core/text-rendering/SdfFontHandler.js +88 -51
- package/dist/src/core/text-rendering/SdfFontHandler.js.map +1 -1
- package/dist/src/core/textures/ImageTexture.js +2 -1
- package/dist/src/core/textures/ImageTexture.js.map +1 -1
- package/dist/src/main-api/Renderer.d.ts +20 -0
- package/dist/src/main-api/Renderer.js +6 -0
- package/dist/src/main-api/Renderer.js.map +1 -1
- package/dist/tsconfig.dist.tsbuildinfo +1 -1
- package/exports/canvas-shaders.ts +1 -0
- package/exports/index.ts +1 -0
- package/exports/webgl-shaders.ts +1 -0
- package/package.json +1 -1
- package/src/core/CoreShaderManager.contextLoss.test.ts +72 -0
- package/src/core/CoreShaderManager.ts +15 -2
- package/src/core/lib/WebGlContextWrapper.ts +16 -0
- package/src/core/lib/validateImageBitmap.test.ts +15 -0
- package/src/core/lib/validateImageBitmap.ts +8 -0
- package/src/core/platforms/web/WebPlatform.loopError.test.ts +145 -0
- package/src/core/platforms/web/WebPlatform.ts +61 -35
- package/src/core/renderers/canvas/CanvasRenderer.ts +20 -0
- package/src/core/renderers/webgl/WebGlRenderer.contextLoss.test.ts +49 -0
- package/src/core/renderers/webgl/WebGlRenderer.ts +13 -1
- package/src/core/shaders/canvas/EdgeFade.ts +105 -0
- package/src/core/shaders/canvas/LinearGradient.test.ts +45 -0
- package/src/core/shaders/canvas/LinearGradient.ts +1 -18
- package/src/core/shaders/templates/EdgeFadeTemplate.test.ts +35 -0
- package/src/core/shaders/templates/EdgeFadeTemplate.ts +41 -0
- package/src/core/shaders/webgl/EdgeFade.ts +84 -0
- package/src/core/shaders/webgl/LinearGradient.test.ts +70 -0
- package/src/core/shaders/webgl/LinearGradient.ts +36 -51
- package/src/core/shaders/webgl/RadialGradient.ts +2 -2
- package/src/core/shaders/webgl/RadialProgress.ts +7 -0
- package/src/core/text-rendering/SdfFontHandler.ts +77 -30
- package/src/core/text-rendering/tests/SdfFontHandler.test.ts +181 -35
- package/src/core/textures/ImageTexture.test.ts +91 -0
- package/src/core/textures/ImageTexture.ts +4 -1
- package/src/main-api/Renderer.ts +28 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CoreNode } from '../../CoreNode.js';
|
|
1
2
|
import { getNormalizedRgbaComponents } from '../../lib/utils.js';
|
|
2
3
|
import {
|
|
3
4
|
LinearGradientTemplate,
|
|
@@ -8,9 +9,33 @@ import type { WebGlShaderType } from '../../renderers/webgl/WebGlShaderNode.js';
|
|
|
8
9
|
|
|
9
10
|
export const LinearGradient: WebGlShaderType<LinearGradientProps> = {
|
|
10
11
|
props: LinearGradientTemplate.props,
|
|
11
|
-
update() {
|
|
12
|
+
update(node: CoreNode) {
|
|
12
13
|
const props = this.props!;
|
|
13
|
-
|
|
14
|
+
|
|
15
|
+
// The gradient distance is an affine function of the node-local texture
|
|
16
|
+
// coordinates, so it reduces to `dist = dot(v_textureCoords, a) + b`.
|
|
17
|
+
// `a`/`b` depend only on the angle and node dimensions (both are part of
|
|
18
|
+
// the value-key), so we compute them once on the CPU here instead of
|
|
19
|
+
// recomputing the trig per fragment on the GPU.
|
|
20
|
+
const angle = props.angle - (Math.PI / 180) * 90;
|
|
21
|
+
const c = Math.cos(angle);
|
|
22
|
+
const s = Math.sin(angle);
|
|
23
|
+
const w = node.w;
|
|
24
|
+
const h = node.h;
|
|
25
|
+
|
|
26
|
+
const lineDist = Math.abs(w * c) + Math.abs(h * s);
|
|
27
|
+
// Gradient axis (from -> to), gradVec = -lineDist * (c, s)
|
|
28
|
+
const gx = -lineDist * c;
|
|
29
|
+
const gy = -lineDist * s;
|
|
30
|
+
const gg = gx * gx + gy * gy;
|
|
31
|
+
const invGG = gg > 0 ? 1 / gg : 0;
|
|
32
|
+
// Gradient origin: f = lineDist * 0.5 * (c, s) + dimensions * 0.5
|
|
33
|
+
const fx = lineDist * 0.5 * c + w * 0.5;
|
|
34
|
+
const fy = lineDist * 0.5 * s + h * 0.5;
|
|
35
|
+
|
|
36
|
+
this.uniform2f('u_grad_a', w * gx * invGG, h * gy * invGG);
|
|
37
|
+
this.uniform1f('u_grad_b', -(fx * gx + fy * gy) * invGG);
|
|
38
|
+
|
|
14
39
|
this.uniform1fv('u_stops', new Float32Array(props.stops));
|
|
15
40
|
const colors: number[] = [];
|
|
16
41
|
for (let i = 0; i < props.colors.length; i++) {
|
|
@@ -22,51 +47,6 @@ export const LinearGradient: WebGlShaderType<LinearGradientProps> = {
|
|
|
22
47
|
getCacheMarkers(props: LinearGradientProps) {
|
|
23
48
|
return `colors:${props.colors.length}`;
|
|
24
49
|
},
|
|
25
|
-
vertex: `
|
|
26
|
-
# ifdef GL_FRAGMENT_PRECISION_HIGH
|
|
27
|
-
precision highp float;
|
|
28
|
-
# else
|
|
29
|
-
precision mediump float;
|
|
30
|
-
# endif
|
|
31
|
-
|
|
32
|
-
attribute vec2 a_position;
|
|
33
|
-
attribute vec2 a_textureCoords;
|
|
34
|
-
attribute vec4 a_color;
|
|
35
|
-
|
|
36
|
-
uniform vec2 u_resolution;
|
|
37
|
-
uniform float u_pixelRatio;
|
|
38
|
-
uniform vec2 u_dimensions;
|
|
39
|
-
uniform float u_angle;
|
|
40
|
-
|
|
41
|
-
varying vec4 v_color;
|
|
42
|
-
varying vec2 v_textureCoords;
|
|
43
|
-
varying float v_dist;
|
|
44
|
-
|
|
45
|
-
const float PI = 3.14159265359;
|
|
46
|
-
|
|
47
|
-
vec2 calcPoint(float d, float angle) {
|
|
48
|
-
return d * vec2(cos(angle), sin(angle)) + (u_dimensions * 0.5);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
void main() {
|
|
52
|
-
vec2 normalized = a_position * u_pixelRatio / u_resolution;
|
|
53
|
-
vec2 zero_two = normalized * 2.0;
|
|
54
|
-
vec2 clip_space = zero_two - 1.0;
|
|
55
|
-
|
|
56
|
-
gl_Position = vec4(clip_space * vec2(1.0, -1.0), 0, 1);
|
|
57
|
-
|
|
58
|
-
v_color = a_color;
|
|
59
|
-
v_textureCoords = a_textureCoords;
|
|
60
|
-
|
|
61
|
-
float a = u_angle;
|
|
62
|
-
float lineDist = abs(u_dimensions.x * cos(a)) + abs(u_dimensions.y * sin(a));
|
|
63
|
-
vec2 f = calcPoint(lineDist * 0.5, a);
|
|
64
|
-
vec2 t = calcPoint(lineDist * 0.5, a + PI);
|
|
65
|
-
vec2 gradVec = t - f;
|
|
66
|
-
float dist = dot(a_textureCoords * u_dimensions - f, gradVec) / dot(gradVec, gradVec);
|
|
67
|
-
v_dist = dist;
|
|
68
|
-
}
|
|
69
|
-
`,
|
|
70
50
|
fragment(renderer: WebGlRenderer, props: LinearGradientProps) {
|
|
71
51
|
return `
|
|
72
52
|
# ifdef GL_FRAGMENT_PRECISION_HIGH
|
|
@@ -78,13 +58,17 @@ export const LinearGradient: WebGlShaderType<LinearGradientProps> = {
|
|
|
78
58
|
#define MAX_STOPS ${props.colors.length}
|
|
79
59
|
#define LAST_STOP ${props.colors.length - 1}
|
|
80
60
|
|
|
61
|
+
uniform float u_alpha;
|
|
62
|
+
|
|
81
63
|
uniform sampler2D u_texture;
|
|
64
|
+
|
|
65
|
+
uniform vec2 u_grad_a;
|
|
66
|
+
uniform float u_grad_b;
|
|
82
67
|
uniform float u_stops[MAX_STOPS];
|
|
83
68
|
uniform vec4 u_colors[MAX_STOPS];
|
|
84
69
|
|
|
85
70
|
varying vec4 v_color;
|
|
86
71
|
varying vec2 v_textureCoords;
|
|
87
|
-
varying float v_dist;
|
|
88
72
|
|
|
89
73
|
vec4 getGradientColor(float dist) {
|
|
90
74
|
dist = clamp(dist, 0.0, 1.0);
|
|
@@ -110,9 +94,10 @@ export const LinearGradient: WebGlShaderType<LinearGradientProps> = {
|
|
|
110
94
|
|
|
111
95
|
void main() {
|
|
112
96
|
vec4 color = texture2D(u_texture, v_textureCoords) * v_color;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
97
|
+
float dist = dot(v_textureCoords, u_grad_a) + u_grad_b;
|
|
98
|
+
vec4 colorOut = getGradientColor(dist);
|
|
99
|
+
color = mix(color, colorOut, clamp(colorOut.a, 0.0, 1.0));
|
|
100
|
+
gl_FragColor = color * u_alpha;
|
|
116
101
|
}
|
|
117
102
|
`;
|
|
118
103
|
},
|
|
@@ -83,8 +83,8 @@ export const RadialGradient: WebGlShaderType<RadialGradientProps> = {
|
|
|
83
83
|
float dist = length((point - u_projection) / u_size);
|
|
84
84
|
|
|
85
85
|
vec4 colorOut = getGradientColor(dist);
|
|
86
|
-
|
|
87
|
-
gl_FragColor =
|
|
86
|
+
color = mix(color, colorOut, clamp(colorOut.a, 0.0, 1.0));
|
|
87
|
+
gl_FragColor = color * u_alpha;
|
|
88
88
|
}
|
|
89
89
|
`;
|
|
90
90
|
},
|
|
@@ -171,6 +171,13 @@ export const RadialProgress: WebGlShaderType<RadialProgressProps> = {
|
|
|
171
171
|
layer = fillPM * fillCoverage;
|
|
172
172
|
#endif
|
|
173
173
|
|
|
174
|
+
// Apply node opacity to the introduced ring/track colors. They come
|
|
175
|
+
// from u_colors / u_trackColor and do not carry worldAlpha, so without
|
|
176
|
+
// this a fading RadialProgress node would keep its ring fully opaque.
|
|
177
|
+
// \`base\` already includes worldAlpha (via v_color), so it is left as-is
|
|
178
|
+
// to avoid double-applying. Scaling a premultiplied layer is valid.
|
|
179
|
+
layer *= u_alpha;
|
|
180
|
+
|
|
174
181
|
// Premultiplied "over": out = src + dst*(1 - src.a). The output stays
|
|
175
182
|
// visible on a fully-transparent \`base\` because layer brings its own alpha.
|
|
176
183
|
float la = clamp(layer.a, 0.0, 1.0);
|
|
@@ -109,6 +109,12 @@ export interface SdfFont {
|
|
|
109
109
|
maxCharHeight: number;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// Number of times a failed font load is automatically retried before
|
|
113
|
+
// loadFont() finally rejects. Counts reloads *after* the initial attempt
|
|
114
|
+
// (matching the `maxRetryCount` = retries convention used for textures), so
|
|
115
|
+
// the load is attempted up to MAX_FONT_LOAD_RETRIES + 1 times in total.
|
|
116
|
+
export const MAX_FONT_LOAD_RETRIES = 3;
|
|
117
|
+
|
|
112
118
|
//global state variables for SdfFontHandler
|
|
113
119
|
const fontCache = new Map<string, SdfFont>();
|
|
114
120
|
const fontLoadPromises = new Map<string, Promise<void>>();
|
|
@@ -320,10 +326,18 @@ export const loadFont = (
|
|
|
320
326
|
);
|
|
321
327
|
}
|
|
322
328
|
|
|
323
|
-
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
329
|
+
// Reuse an existing waiter list. A previous load attempt for this font may
|
|
330
|
+
// have failed and left nodes parked here; overwriting the list would strand
|
|
331
|
+
// them, so a successful retry could never wake them. The list is consumed
|
|
332
|
+
// (and deleted) on the next successful load.
|
|
333
|
+
let nwff = nodesWaitingForFont[fontFamily];
|
|
334
|
+
if (nwff === undefined) {
|
|
335
|
+
nwff = nodesWaitingForFont[fontFamily] = [];
|
|
336
|
+
}
|
|
337
|
+
// One attempt at fetching + decoding the JSON atlas description. A fresh
|
|
338
|
+
// XHR runs per attempt so a transient network/parse failure can recover.
|
|
339
|
+
const fetchFontData = (): Promise<SdfFontData> =>
|
|
340
|
+
new Promise<SdfFontData>((resolve, reject) => {
|
|
327
341
|
const xhr = new XMLHttpRequest();
|
|
328
342
|
xhr.open('GET', atlasDataUrl, true);
|
|
329
343
|
xhr.responseType = 'json';
|
|
@@ -352,16 +366,23 @@ export const loadFont = (
|
|
|
352
366
|
};
|
|
353
367
|
xhr.send(null);
|
|
354
368
|
});
|
|
369
|
+
|
|
370
|
+
// One attempt at loading the atlas texture for the given font data. On
|
|
371
|
+
// success it processes + caches the font and wakes parked nodes. On failure
|
|
372
|
+
// it drops the dead atlas texture (createTexture caches by `src`, so the
|
|
373
|
+
// next attempt must evict it to build a fresh one) and rejects.
|
|
374
|
+
const loadAtlas = (fontData: SdfFontData): Promise<void> => {
|
|
355
375
|
if (!fontData || !fontData.chars) {
|
|
356
|
-
|
|
376
|
+
return Promise.reject(new Error('Invalid SDF font data format'));
|
|
357
377
|
}
|
|
358
378
|
|
|
359
379
|
// Atlas texture should be provided externally
|
|
360
380
|
if (!atlasUrl) {
|
|
361
|
-
|
|
381
|
+
return Promise.reject(
|
|
382
|
+
new Error('Atlas texture must be provided for SDF fonts'),
|
|
383
|
+
);
|
|
362
384
|
}
|
|
363
385
|
|
|
364
|
-
// Wait for atlas texture to load
|
|
365
386
|
return new Promise<void>((resolve, reject) => {
|
|
366
387
|
// create new atlas texture using ImageTexture
|
|
367
388
|
const atlasTexture = stage.txManager.createTexture('ImageTexture', {
|
|
@@ -372,46 +393,72 @@ export const loadFont = (
|
|
|
372
393
|
atlasTexture.setRenderableOwner(fontFamily, true);
|
|
373
394
|
atlasTexture.preventCleanup = true; // Prevent automatic cleanup
|
|
374
395
|
|
|
375
|
-
|
|
376
|
-
// If already loaded, process immediately
|
|
377
|
-
processFontData(fontFamily, fontData, atlasTexture, metrics);
|
|
378
|
-
fontLoadPromises.delete(fontFamily);
|
|
379
|
-
|
|
380
|
-
for (let key in nwff) {
|
|
381
|
-
nwff[key]!.setUpdateType(UpdateType.Local);
|
|
382
|
-
}
|
|
383
|
-
delete nodesWaitingForFont[fontFamily];
|
|
384
|
-
return resolve();
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
atlasTexture.on('loaded', () => {
|
|
396
|
+
const onLoaded = () => {
|
|
388
397
|
// Process and cache font data
|
|
389
398
|
processFontData(fontFamily, fontData, atlasTexture, metrics);
|
|
390
399
|
|
|
391
|
-
// remove from promises
|
|
392
|
-
fontLoadPromises.delete(fontFamily);
|
|
393
|
-
|
|
394
400
|
for (let key in nwff) {
|
|
395
401
|
nwff[key]!.setUpdateType(UpdateType.Local);
|
|
396
402
|
}
|
|
397
403
|
delete nodesWaitingForFont[fontFamily];
|
|
398
404
|
resolve();
|
|
399
|
-
}
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
if (atlasTexture.state === 'loaded') {
|
|
408
|
+
// If already loaded, process immediately
|
|
409
|
+
onLoaded();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
atlasTexture.on('loaded', onLoaded);
|
|
400
414
|
|
|
401
415
|
// EventEmitter invokes listeners as (target, data), so the error payload
|
|
402
416
|
// is the SECOND argument. The first arg is the Texture that emitted the
|
|
403
417
|
// event. Reading it as the only param (the previous behavior) rejected
|
|
404
418
|
// and logged the Texture instead of the actual TextureError.
|
|
405
419
|
atlasTexture.on('failed', (_target, error: TextureError) => {
|
|
406
|
-
//
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
}
|
|
411
|
-
console.error(`Failed to load SDF font: ${fontFamily}`, error);
|
|
420
|
+
// Drop the failed atlas so a retry builds a fresh texture rather than
|
|
421
|
+
// getting this dead instance back from the createTexture key-cache.
|
|
422
|
+
atlasTexture.setRenderableOwner(fontFamily, false);
|
|
423
|
+
stage.txManager.removeTextureFromCache(atlasTexture);
|
|
412
424
|
reject(error);
|
|
413
425
|
});
|
|
414
426
|
});
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// Initial attempt plus up to MAX_FONT_LOAD_RETRIES automatic reloads.
|
|
430
|
+
const loadPromise = (async (): Promise<void> => {
|
|
431
|
+
let lastError: unknown;
|
|
432
|
+
for (let attempt = 0; attempt <= MAX_FONT_LOAD_RETRIES; attempt++) {
|
|
433
|
+
try {
|
|
434
|
+
await loadAtlas(await fetchFontData());
|
|
435
|
+
// Success: clear the in-flight marker (the font now lives in fontCache)
|
|
436
|
+
// — parked nodes were already woken inside loadAtlas.
|
|
437
|
+
fontLoadPromises.delete(fontFamily);
|
|
438
|
+
return;
|
|
439
|
+
} catch (error) {
|
|
440
|
+
lastError = error;
|
|
441
|
+
if (attempt < MAX_FONT_LOAD_RETRIES) {
|
|
442
|
+
console.warn(
|
|
443
|
+
`SDF font "${fontFamily}" failed to load (attempt ${
|
|
444
|
+
attempt + 1
|
|
445
|
+
} of ${MAX_FONT_LOAD_RETRIES + 1}), retrying.`,
|
|
446
|
+
error,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Every attempt failed. Clear the in-flight marker so the font can be
|
|
453
|
+
// requested again and drop any partial cache entry. nodesWaitingForFont
|
|
454
|
+
// is deliberately kept: nodes parked here must survive so a later
|
|
455
|
+
// loadFont() (which reuses the list) can still wake them if the font
|
|
456
|
+
// eventually loads. The list shrinks as nodes self-remove via
|
|
457
|
+
// stopWaitingForFont on destroy.
|
|
458
|
+
fontLoadPromises.delete(fontFamily);
|
|
459
|
+
fontCache.delete(fontFamily);
|
|
460
|
+
console.error(`Failed to load SDF font: ${fontFamily}`, lastError);
|
|
461
|
+
throw lastError;
|
|
415
462
|
})();
|
|
416
463
|
|
|
417
464
|
fontLoadPromises.set(fontFamily, loadPromise);
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
loadFont,
|
|
4
|
+
waitingForFont,
|
|
5
|
+
isFontLoaded,
|
|
6
|
+
MAX_FONT_LOAD_RETRIES,
|
|
7
|
+
} from '../SdfFontHandler.js';
|
|
3
8
|
import { EventEmitter } from '../../../common/EventEmitter.js';
|
|
4
9
|
import { TextureError, TextureErrorCode } from '../../TextureError.js';
|
|
5
10
|
import type { Stage } from '../../Stage.js';
|
|
@@ -14,7 +19,19 @@ class FakeXHR {
|
|
|
14
19
|
onerror: (() => void) | null = null;
|
|
15
20
|
open(): void {}
|
|
16
21
|
send(): void {
|
|
17
|
-
|
|
22
|
+
// Enough shape for processFontData to run on the success path without
|
|
23
|
+
// throwing: a chars array, an (empty) kernings array, and metrics so it
|
|
24
|
+
// skips the atlas-derived cap/x-height branches.
|
|
25
|
+
this.response = {
|
|
26
|
+
chars: [{}],
|
|
27
|
+
kernings: [],
|
|
28
|
+
lightningMetrics: {
|
|
29
|
+
ascender: 800,
|
|
30
|
+
descender: -200,
|
|
31
|
+
lineGap: 200,
|
|
32
|
+
unitsPerEm: 1000,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
18
35
|
if (this.onload !== null) {
|
|
19
36
|
this.onload();
|
|
20
37
|
}
|
|
@@ -34,15 +51,59 @@ function makeFakeTexture() {
|
|
|
34
51
|
return tex;
|
|
35
52
|
}
|
|
36
53
|
|
|
54
|
+
type FakeTexture = ReturnType<typeof makeFakeTexture>;
|
|
55
|
+
|
|
56
|
+
// A stage whose txManager hands out a fresh fake texture per loadFont attempt
|
|
57
|
+
// (so each retry has its own texture to fail/succeed) and records them.
|
|
58
|
+
function makeStage(): { stage: Stage; textures: FakeTexture[] } {
|
|
59
|
+
const textures: FakeTexture[] = [];
|
|
60
|
+
const stage = {
|
|
61
|
+
txManager: {
|
|
62
|
+
createTexture: () => {
|
|
63
|
+
const t = makeFakeTexture();
|
|
64
|
+
textures.push(t);
|
|
65
|
+
return t;
|
|
66
|
+
},
|
|
67
|
+
// loadFont evicts a failed atlas before retrying; a no-op is enough here.
|
|
68
|
+
removeTextureFromCache: () => {},
|
|
69
|
+
},
|
|
70
|
+
} as unknown as Stage;
|
|
71
|
+
return { stage, textures };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const opts = (fontFamily: string) =>
|
|
75
|
+
({
|
|
76
|
+
fontFamily,
|
|
77
|
+
atlasUrl: 'atlas.png',
|
|
78
|
+
atlasDataUrl: 'atlas.json',
|
|
79
|
+
} as Parameters<typeof loadFont>[1]);
|
|
80
|
+
|
|
37
81
|
// Drain microtasks + one macrotask so the async loader reaches listener setup.
|
|
38
82
|
const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
|
|
39
83
|
|
|
40
|
-
|
|
84
|
+
// Fail every attempt: each iteration waits for the next attempt to wire up its
|
|
85
|
+
// atlas listener, then fires 'failed' on that attempt's texture.
|
|
86
|
+
async function failAllAttempts(
|
|
87
|
+
textures: FakeTexture[],
|
|
88
|
+
error: TextureError,
|
|
89
|
+
attempts: number,
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
for (let i = 0; i < attempts; i++) {
|
|
92
|
+
await flush();
|
|
93
|
+
textures[i]!.emit('failed', error);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const TOTAL_ATTEMPTS = MAX_FONT_LOAD_RETRIES + 1; // initial + retries
|
|
98
|
+
|
|
99
|
+
describe('SdfFontHandler loadFont — failure after exhausting retries', () => {
|
|
41
100
|
let errSpy: ReturnType<typeof vi.spyOn>;
|
|
101
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
42
102
|
let originalXHR: unknown;
|
|
43
103
|
|
|
44
104
|
beforeEach(() => {
|
|
45
105
|
errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
106
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
46
107
|
originalXHR = (globalThis as unknown as { XMLHttpRequest: unknown })
|
|
47
108
|
.XMLHttpRequest;
|
|
48
109
|
(globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
|
|
@@ -51,62 +112,147 @@ describe('SdfFontHandler loadFont — failed event argument', () => {
|
|
|
51
112
|
|
|
52
113
|
afterEach(() => {
|
|
53
114
|
errSpy.mockRestore();
|
|
115
|
+
warnSpy.mockRestore();
|
|
54
116
|
(globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
|
|
55
117
|
originalXHR;
|
|
56
118
|
});
|
|
57
119
|
|
|
58
|
-
it('rejects with the TextureError (second emit arg), not the emitting texture', async () => {
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
txManager: { createTexture: () => tex },
|
|
62
|
-
} as unknown as Stage;
|
|
120
|
+
it('rejects with the last TextureError (second emit arg), not the emitting texture', async () => {
|
|
121
|
+
const { stage, textures } = makeStage();
|
|
122
|
+
const promise = loadFont(stage, opts('TestSdfFailFont'));
|
|
63
123
|
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
} as Parameters<typeof loadFont>[1]);
|
|
124
|
+
const error = new TextureError(
|
|
125
|
+
TextureErrorCode.TEXTURE_UPLOAD_FAILED,
|
|
126
|
+
'boom',
|
|
127
|
+
);
|
|
69
128
|
|
|
70
|
-
|
|
129
|
+
// Attach the rejection assertion before driving failures.
|
|
130
|
+
const assertion = expect(promise).rejects.toBe(error);
|
|
131
|
+
await failAllAttempts(textures, error, TOTAL_ATTEMPTS);
|
|
132
|
+
await assertion;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('logs the error, not the texture, after the final attempt', async () => {
|
|
136
|
+
const { stage, textures } = makeStage();
|
|
137
|
+
const promise = loadFont(stage, opts('TestSdfFailFontLog'));
|
|
71
138
|
|
|
72
139
|
const error = new TextureError(
|
|
73
140
|
TextureErrorCode.TEXTURE_UPLOAD_FAILED,
|
|
74
141
|
'boom',
|
|
75
142
|
);
|
|
143
|
+
const rejected = promise.catch(() => {});
|
|
144
|
+
await failAllAttempts(textures, error, TOTAL_ATTEMPTS);
|
|
145
|
+
await rejected;
|
|
76
146
|
|
|
77
|
-
|
|
78
|
-
|
|
147
|
+
const lastCall = errSpy.mock.calls[errSpy.mock.calls.length - 1]!;
|
|
148
|
+
expect(lastCall[1]).toBe(error);
|
|
149
|
+
expect(lastCall[1]).not.toBe(textures[textures.length - 1]);
|
|
150
|
+
});
|
|
79
151
|
|
|
80
|
-
|
|
81
|
-
|
|
152
|
+
it('attempts the load exactly initial + MAX_FONT_LOAD_RETRIES times', async () => {
|
|
153
|
+
const { stage, textures } = makeStage();
|
|
154
|
+
const promise = loadFont(stage, opts('TestSdfAttemptCount'));
|
|
82
155
|
|
|
83
|
-
|
|
156
|
+
const error = new TextureError(
|
|
157
|
+
TextureErrorCode.TEXTURE_UPLOAD_FAILED,
|
|
158
|
+
'boom',
|
|
159
|
+
);
|
|
160
|
+
const rejected = promise.catch(() => {});
|
|
161
|
+
await failAllAttempts(textures, error, TOTAL_ATTEMPTS);
|
|
162
|
+
await rejected;
|
|
163
|
+
|
|
164
|
+
// One texture is created per attempt; no further attempts after exhaustion.
|
|
165
|
+
expect(textures.length).toBe(TOTAL_ATTEMPTS);
|
|
166
|
+
// A warning per retried attempt, error only on the final failure.
|
|
167
|
+
expect(warnSpy).toHaveBeenCalledTimes(MAX_FONT_LOAD_RETRIES);
|
|
168
|
+
expect(errSpy).toHaveBeenCalledTimes(1);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('SdfFontHandler loadFont — automatic retry', () => {
|
|
173
|
+
let errSpy: ReturnType<typeof vi.spyOn>;
|
|
174
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
175
|
+
let originalXHR: unknown;
|
|
176
|
+
|
|
177
|
+
beforeEach(() => {
|
|
178
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
179
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
180
|
+
originalXHR = (globalThis as unknown as { XMLHttpRequest: unknown })
|
|
181
|
+
.XMLHttpRequest;
|
|
182
|
+
(globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
|
|
183
|
+
FakeXHR;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
afterEach(() => {
|
|
187
|
+
errSpy.mockRestore();
|
|
188
|
+
warnSpy.mockRestore();
|
|
189
|
+
(globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
|
|
190
|
+
originalXHR;
|
|
84
191
|
});
|
|
85
192
|
|
|
86
|
-
it('
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
193
|
+
it('recovers when a retry succeeds and wakes nodes parked before the failure', async () => {
|
|
194
|
+
const { stage, textures } = makeStage();
|
|
195
|
+
const fontFamily = 'RetryRecoverFont';
|
|
196
|
+
|
|
197
|
+
const promise = loadFont(stage, opts(fontFamily));
|
|
91
198
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
199
|
+
// Park a node while the font is loading (waiter list exists synchronously).
|
|
200
|
+
const node = { id: 1, setUpdateType: vi.fn() };
|
|
201
|
+
waitingForFont(
|
|
202
|
+
fontFamily,
|
|
203
|
+
node as unknown as Parameters<typeof waitingForFont>[1],
|
|
204
|
+
);
|
|
97
205
|
|
|
206
|
+
// First attempt fails...
|
|
98
207
|
await flush();
|
|
208
|
+
textures[0]!.emit(
|
|
209
|
+
'failed',
|
|
210
|
+
new TextureError(TextureErrorCode.TEXTURE_UPLOAD_FAILED, 'boom'),
|
|
211
|
+
);
|
|
99
212
|
|
|
213
|
+
// ...the automatic retry succeeds.
|
|
214
|
+
await flush();
|
|
215
|
+
textures[1]!.emit('loaded');
|
|
216
|
+
await promise;
|
|
217
|
+
|
|
218
|
+
expect(isFontLoaded(fontFamily)).toBe(true);
|
|
219
|
+
expect(node.setUpdateType).toHaveBeenCalledTimes(1);
|
|
220
|
+
// Only two attempts were needed.
|
|
221
|
+
expect(textures.length).toBe(2);
|
|
222
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
223
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('keeps parked nodes after every retry fails so a later loadFont still wakes them', async () => {
|
|
227
|
+
const { stage, textures } = makeStage();
|
|
228
|
+
const fontFamily = 'RetryExhaustReuseFont';
|
|
100
229
|
const error = new TextureError(
|
|
101
230
|
TextureErrorCode.TEXTURE_UPLOAD_FAILED,
|
|
102
231
|
'boom',
|
|
103
232
|
);
|
|
104
|
-
const rejected = promise.catch(() => {});
|
|
105
|
-
tex.emit('failed', error);
|
|
106
|
-
await rejected;
|
|
107
233
|
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
234
|
+
const first = loadFont(stage, opts(fontFamily));
|
|
235
|
+
const firstRejected = first.catch(() => {});
|
|
236
|
+
|
|
237
|
+
const node = { id: 1, setUpdateType: vi.fn() };
|
|
238
|
+
waitingForFont(
|
|
239
|
+
fontFamily,
|
|
240
|
+
node as unknown as Parameters<typeof waitingForFont>[1],
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
await failAllAttempts(textures, error, TOTAL_ATTEMPTS);
|
|
244
|
+
await firstRejected;
|
|
245
|
+
|
|
246
|
+
expect(isFontLoaded(fontFamily)).toBe(false);
|
|
247
|
+
expect(node.setUpdateType).not.toHaveBeenCalled();
|
|
248
|
+
|
|
249
|
+
// A fresh load reuses the still-parked node and wakes it on success.
|
|
250
|
+
const second = loadFont(stage, opts(fontFamily));
|
|
251
|
+
await flush();
|
|
252
|
+
textures[TOTAL_ATTEMPTS]!.emit('loaded');
|
|
253
|
+
await second;
|
|
254
|
+
|
|
255
|
+
expect(isFontLoaded(fontFamily)).toBe(true);
|
|
256
|
+
expect(node.setUpdateType).toHaveBeenCalledTimes(1);
|
|
111
257
|
});
|
|
112
258
|
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { ImageTexture } from './ImageTexture.js';
|
|
3
|
+
import type { CoreTextureManager } from '../CoreTextureManager.js';
|
|
4
|
+
|
|
5
|
+
describe('ImageTexture.createImageBitmap', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.stubGlobal('ImageBitmap', class {});
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('passes options to createImageBitmap when no crop is requested and options/full are supported', async () => {
|
|
11
|
+
const createImageBitmapMock = vi.fn(() =>
|
|
12
|
+
Promise.resolve({ close: () => {} }),
|
|
13
|
+
);
|
|
14
|
+
const txManager = {
|
|
15
|
+
imageBitmapSupported: {
|
|
16
|
+
basic: true,
|
|
17
|
+
options: true,
|
|
18
|
+
full: true,
|
|
19
|
+
premultiplyHonored: false,
|
|
20
|
+
},
|
|
21
|
+
platform: {
|
|
22
|
+
createImageBitmap: createImageBitmapMock,
|
|
23
|
+
},
|
|
24
|
+
} as unknown as CoreTextureManager;
|
|
25
|
+
|
|
26
|
+
const props = ImageTexture.resolveDefaults({
|
|
27
|
+
src: 'test.png',
|
|
28
|
+
premultiplyAlpha: true,
|
|
29
|
+
});
|
|
30
|
+
const texture = new ImageTexture(txManager, props);
|
|
31
|
+
|
|
32
|
+
const blob = new Blob([], { type: 'image/png' });
|
|
33
|
+
const result = await texture.createImageBitmap(
|
|
34
|
+
blob,
|
|
35
|
+
true,
|
|
36
|
+
null,
|
|
37
|
+
null,
|
|
38
|
+
null,
|
|
39
|
+
null,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// It should have called createImageBitmap with the options object
|
|
43
|
+
expect(createImageBitmapMock).toHaveBeenCalledWith(blob, {
|
|
44
|
+
premultiplyAlpha: 'none',
|
|
45
|
+
colorSpaceConversion: 'none',
|
|
46
|
+
imageOrientation: 'none',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Since premultiplyHonored is false, useGlPremultiply should be true
|
|
50
|
+
expect(result.premultiplyAlpha).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('falls back to basic createImageBitmap when options are not supported', async () => {
|
|
54
|
+
const createImageBitmapMock = vi.fn(() =>
|
|
55
|
+
Promise.resolve({ close: () => {} }),
|
|
56
|
+
);
|
|
57
|
+
const txManager = {
|
|
58
|
+
imageBitmapSupported: {
|
|
59
|
+
basic: true,
|
|
60
|
+
options: false,
|
|
61
|
+
full: false,
|
|
62
|
+
premultiplyHonored: null,
|
|
63
|
+
},
|
|
64
|
+
platform: {
|
|
65
|
+
createImageBitmap: createImageBitmapMock,
|
|
66
|
+
},
|
|
67
|
+
} as unknown as CoreTextureManager;
|
|
68
|
+
|
|
69
|
+
const props = ImageTexture.resolveDefaults({
|
|
70
|
+
src: 'test.png',
|
|
71
|
+
premultiplyAlpha: true,
|
|
72
|
+
});
|
|
73
|
+
const texture = new ImageTexture(txManager, props);
|
|
74
|
+
|
|
75
|
+
const blob = new Blob([], { type: 'image/png' });
|
|
76
|
+
const result = await texture.createImageBitmap(
|
|
77
|
+
blob,
|
|
78
|
+
true,
|
|
79
|
+
null,
|
|
80
|
+
null,
|
|
81
|
+
null,
|
|
82
|
+
null,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// It should have called basic createImageBitmap without options
|
|
86
|
+
expect(createImageBitmapMock).toHaveBeenCalledWith(blob);
|
|
87
|
+
|
|
88
|
+
// And premultiplyAlpha should be false (browser default is assumed to premultiply)
|
|
89
|
+
expect(result.premultiplyAlpha).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
});
|