@solidtv/renderer 1.6.1 → 1.6.3

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 (36) hide show
  1. package/dist/exports/index.d.ts +1 -0
  2. package/dist/exports/index.js +3 -0
  3. package/dist/exports/index.js.map +1 -1
  4. package/dist/src/core/lib/ImageWorker.js +29 -20
  5. package/dist/src/core/lib/ImageWorker.js.map +1 -1
  6. package/dist/src/core/lib/validateImageBitmap.d.ts +13 -7
  7. package/dist/src/core/lib/validateImageBitmap.js +31 -10
  8. package/dist/src/core/lib/validateImageBitmap.js.map +1 -1
  9. package/dist/src/core/text-rendering/CanvasFontHandler.js +51 -16
  10. package/dist/src/core/text-rendering/CanvasFontHandler.js.map +1 -1
  11. package/dist/src/core/text-rendering/FontPrefetch.d.ts +55 -0
  12. package/dist/src/core/text-rendering/FontPrefetch.js +140 -0
  13. package/dist/src/core/text-rendering/FontPrefetch.js.map +1 -0
  14. package/dist/src/core/text-rendering/SdfFontHandler.js +91 -30
  15. package/dist/src/core/text-rendering/SdfFontHandler.js.map +1 -1
  16. package/dist/src/core/text-rendering/TextRenderer.d.ts +10 -1
  17. package/dist/src/core/textures/ImageTexture.js +4 -2
  18. package/dist/src/core/textures/ImageTexture.js.map +1 -1
  19. package/dist/src/main-api/Renderer.d.ts +8 -4
  20. package/dist/src/main-api/Renderer.js.map +1 -1
  21. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  22. package/exports/index.ts +7 -0
  23. package/package.json +1 -1
  24. package/src/core/lib/ImageWorker.test.ts +19 -0
  25. package/src/core/lib/ImageWorker.ts +34 -40
  26. package/src/core/lib/validateImageBitmap.test.ts +17 -10
  27. package/src/core/lib/validateImageBitmap.ts +32 -14
  28. package/src/core/text-rendering/CanvasFontHandler.ts +57 -16
  29. package/src/core/text-rendering/FontPrefetch.ts +195 -0
  30. package/src/core/text-rendering/SdfFontHandler.ts +102 -32
  31. package/src/core/text-rendering/TextRenderer.ts +10 -1
  32. package/src/core/text-rendering/tests/FontPrefetch.test.ts +341 -0
  33. package/src/core/text-rendering/tests/SdfFontHandler.test.ts +108 -1
  34. package/src/core/textures/ImageTexture.test.ts +94 -26
  35. package/src/core/textures/ImageTexture.ts +4 -2
  36. package/src/main-api/Renderer.ts +8 -4
@@ -0,0 +1,341 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ prefetchFont,
4
+ clearFontPrefetch,
5
+ takeSdfPrefetch,
6
+ takeCanvasPrefetch,
7
+ } from '../FontPrefetch.js';
8
+ import { loadFont as loadSdfFont, isFontLoaded } from '../SdfFontHandler.js';
9
+ import {
10
+ loadFont as loadCanvasFont,
11
+ isFontLoaded as isCanvasFontLoaded,
12
+ } from '../CanvasFontHandler.js';
13
+ import { EventEmitter } from '../../../common/EventEmitter.js';
14
+ import type { Stage } from '../../Stage.js';
15
+ import type { ImageTextureProps } from '../../textures/ImageTexture.js';
16
+
17
+ const VALID_FONT_DATA = {
18
+ chars: [{}],
19
+ kernings: [],
20
+ lightningMetrics: {
21
+ ascender: 800,
22
+ descender: -200,
23
+ lineGap: 200,
24
+ unitsPerEm: 1000,
25
+ },
26
+ };
27
+
28
+ // Records every request so tests can assert what was fetched, when, and in
29
+ // what order. Responses are keyed by URL and delivered on demand, letting a
30
+ // test hold a request open to observe concurrency.
31
+ class RecordingXHR {
32
+ static requests: RecordingXHR[] = [];
33
+ static responses: Record<string, unknown> = {};
34
+
35
+ status = 200;
36
+ response: unknown = null;
37
+ responseType = '';
38
+ url = '';
39
+ onload: (() => void) | null = null;
40
+ onerror: (() => void) | null = null;
41
+ onreadystatechange: (() => void) | null = null;
42
+ readyState = 0;
43
+
44
+ open(_method: string, url: string): void {
45
+ this.url = url;
46
+ }
47
+
48
+ send(): void {
49
+ RecordingXHR.requests.push(this);
50
+ }
51
+
52
+ /** Deliver the configured response for this request's URL. */
53
+ respond(status = 200): void {
54
+ this.status = status;
55
+ this.response = RecordingXHR.responses[this.url] ?? null;
56
+ this.readyState = 4;
57
+ if (this.onreadystatechange !== null) this.onreadystatechange();
58
+ if (this.onload !== null) this.onload();
59
+ }
60
+
61
+ /** Deliver every outstanding request. */
62
+ static respondAll(): void {
63
+ RecordingXHR.requests.forEach((r) => r.respond());
64
+ }
65
+
66
+ static reset(): void {
67
+ RecordingXHR.requests = [];
68
+ RecordingXHR.responses = {};
69
+ }
70
+ }
71
+
72
+ // `XMLHttpRequest.DONE` is read by fetchJson; the class stand-in must carry it.
73
+ (RecordingXHR as unknown as { DONE: number }).DONE = 4;
74
+
75
+ function makeFakeTexture() {
76
+ const tex = new EventEmitter() as unknown as EventEmitter & {
77
+ state: string;
78
+ preventCleanup: boolean;
79
+ setRenderableOwner: (owner: string, val: boolean) => void;
80
+ };
81
+ tex.state = 'loading';
82
+ tex.preventCleanup = false;
83
+ tex.setRenderableOwner = () => {};
84
+ return tex;
85
+ }
86
+
87
+ type FakeTexture = ReturnType<typeof makeFakeTexture>;
88
+
89
+ // Stage stub that records the props each atlas texture was created with, so
90
+ // tests can assert whether the prefetched blob (or the URL) was used.
91
+ function makeStage(): {
92
+ stage: Stage;
93
+ textures: FakeTexture[];
94
+ textureProps: ImageTextureProps[];
95
+ } {
96
+ const textures: FakeTexture[] = [];
97
+ const textureProps: ImageTextureProps[] = [];
98
+ const stage = {
99
+ txManager: {
100
+ createTexture: (_type: string, props: ImageTextureProps) => {
101
+ textureProps.push(props);
102
+ const t = makeFakeTexture();
103
+ textures.push(t);
104
+ return t;
105
+ },
106
+ removeTextureFromCache: () => {},
107
+ },
108
+ } as unknown as Stage;
109
+ return { stage, textures, textureProps };
110
+ }
111
+
112
+ const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
113
+
114
+ const sdfOpts = (fontFamily: string) => ({
115
+ fontFamily,
116
+ atlasUrl: `${fontFamily}.png`,
117
+ atlasDataUrl: `${fontFamily}.json`,
118
+ });
119
+
120
+ describe('FontPrefetch — SDF', () => {
121
+ let originalXHR: unknown;
122
+ let originalBlob: unknown;
123
+
124
+ beforeEach(() => {
125
+ originalXHR = (globalThis as unknown as { XMLHttpRequest: unknown })
126
+ .XMLHttpRequest;
127
+ (globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
128
+ RecordingXHR;
129
+ // jsdom provides Blob; guard for environments that do not.
130
+ originalBlob = (globalThis as unknown as { Blob: unknown }).Blob;
131
+ RecordingXHR.reset();
132
+ clearFontPrefetch();
133
+ });
134
+
135
+ afterEach(() => {
136
+ (globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
137
+ originalXHR;
138
+ (globalThis as unknown as { Blob: unknown }).Blob = originalBlob;
139
+ clearFontPrefetch();
140
+ });
141
+
142
+ it('requests the atlas description and the atlas image concurrently', () => {
143
+ prefetchFont(sdfOpts('ConcurrentFont'));
144
+
145
+ // Both are in flight before either has responded — the handler's own path
146
+ // only starts the image fetch after the JSON resolves.
147
+ expect(RecordingXHR.requests.map((r) => r.url).sort()).toEqual([
148
+ 'ConcurrentFont.json',
149
+ 'ConcurrentFont.png',
150
+ ]);
151
+ });
152
+
153
+ it('starts no request for a font it has already prefetched', () => {
154
+ prefetchFont(sdfOpts('DedupeFont'));
155
+ expect(RecordingXHR.requests.length).toBe(2);
156
+
157
+ prefetchFont(sdfOpts('DedupeFont'));
158
+ expect(RecordingXHR.requests.length).toBe(2);
159
+ });
160
+
161
+ it('hands the prefetched payload to loadFont instead of refetching', async () => {
162
+ const font = sdfOpts('PrefetchHit');
163
+ const blob = new Blob(['atlas-bytes']);
164
+ RecordingXHR.responses['PrefetchHit.json'] = VALID_FONT_DATA;
165
+ RecordingXHR.responses['PrefetchHit.png'] = blob;
166
+
167
+ prefetchFont(font);
168
+ RecordingXHR.respondAll();
169
+ const prefetchRequestCount = RecordingXHR.requests.length;
170
+
171
+ const { stage, textures, textureProps } = makeStage();
172
+ const promise = loadSdfFont(
173
+ stage,
174
+ font as Parameters<typeof loadSdfFont>[1],
175
+ );
176
+ await flush();
177
+ textures[0]!.emit('loaded');
178
+ await promise;
179
+
180
+ // No second trip to the network for either resource.
181
+ expect(RecordingXHR.requests.length).toBe(prefetchRequestCount);
182
+ // The atlas texture was built from the prefetched bytes...
183
+ expect(textureProps[0]!.src).toBe(blob);
184
+ // ...while still keying the texture cache by URL, so the blob path and the
185
+ // URL path resolve to the same cache entry.
186
+ expect(textureProps[0]!.key).toBe('PrefetchHit.png');
187
+ expect(isFontLoaded('PrefetchHit')).toBe(true);
188
+ });
189
+
190
+ it('falls back to fetching when the prefetch failed', async () => {
191
+ const font = sdfOpts('PrefetchFail');
192
+ // No responses registered -> fetchJson resolves null -> unusable prefetch.
193
+ prefetchFont(font);
194
+ RecordingXHR.respondAll();
195
+ RecordingXHR.reset();
196
+
197
+ RecordingXHR.responses['PrefetchFail.json'] = VALID_FONT_DATA;
198
+
199
+ const { stage, textures, textureProps } = makeStage();
200
+ const promise = loadSdfFont(
201
+ stage,
202
+ font as Parameters<typeof loadSdfFont>[1],
203
+ );
204
+ // The handler runs its own XHR for the atlas description.
205
+ await flush();
206
+ RecordingXHR.respondAll();
207
+ await flush();
208
+ textures[0]!.emit('loaded');
209
+ await promise;
210
+
211
+ expect(RecordingXHR.requests.map((r) => r.url)).toEqual([
212
+ 'PrefetchFail.json',
213
+ ]);
214
+ // Atlas falls back to the URL rather than a blob.
215
+ expect(textureProps[0]!.src).toBe('PrefetchFail.png');
216
+ expect(isFontLoaded('PrefetchFail')).toBe(true);
217
+ });
218
+
219
+ it('rejects a malformed atlas description rather than loading it', async () => {
220
+ RecordingXHR.responses['Malformed.json'] = { not: 'font data' };
221
+ prefetchFont(sdfOpts('Malformed'));
222
+ RecordingXHR.respondAll();
223
+
224
+ await expect(takeSdfPrefetch('Malformed')!.data).resolves.toBe(null);
225
+ });
226
+
227
+ it('parses an atlas description that arrives as a string', async () => {
228
+ RecordingXHR.responses['StringJson.json'] = JSON.stringify(VALID_FONT_DATA);
229
+ prefetchFont(sdfOpts('StringJson'));
230
+ RecordingXHR.respondAll();
231
+
232
+ await expect(takeSdfPrefetch('StringJson')!.data).resolves.toMatchObject({
233
+ chars: [{}],
234
+ });
235
+ });
236
+
237
+ it('claims a prefetch only once', () => {
238
+ prefetchFont(sdfOpts('OnceOnly'));
239
+
240
+ expect(takeSdfPrefetch('OnceOnly')).not.toBe(undefined);
241
+ expect(takeSdfPrefetch('OnceOnly')).toBe(undefined);
242
+ });
243
+
244
+ it('ignores a canvas-typed descriptor even when it carries atlas urls', () => {
245
+ prefetchFont({ ...sdfOpts('CanvasTyped'), type: 'canvas' });
246
+
247
+ expect(takeSdfPrefetch('CanvasTyped')).toBe(undefined);
248
+ });
249
+ });
250
+
251
+ describe('FontPrefetch — web fonts', () => {
252
+ class FakeFontFace {
253
+ static created: FakeFontFace[] = [];
254
+ constructor(public family: string, public source: string) {
255
+ FakeFontFace.created.push(this);
256
+ }
257
+ load(): Promise<FakeFontFace> {
258
+ return Promise.resolve(this);
259
+ }
260
+ }
261
+
262
+ let originalFontFace: unknown;
263
+
264
+ beforeEach(() => {
265
+ originalFontFace = (globalThis as unknown as { FontFace: unknown })
266
+ .FontFace;
267
+ (globalThis as unknown as { FontFace: unknown }).FontFace = FakeFontFace;
268
+ FakeFontFace.created = [];
269
+ clearFontPrefetch();
270
+ });
271
+
272
+ afterEach(() => {
273
+ (globalThis as unknown as { FontFace: unknown }).FontFace =
274
+ originalFontFace;
275
+ clearFontPrefetch();
276
+ });
277
+
278
+ it('starts the download for every registered name', async () => {
279
+ prefetchFont({
280
+ fontFamily: ['WebPrimary', 'WebAlias'],
281
+ fontUrl: 'web.woff2',
282
+ });
283
+
284
+ expect(FakeFontFace.created.map((f) => f.family)).toEqual([
285
+ 'WebPrimary',
286
+ 'WebAlias',
287
+ ]);
288
+ await expect(takeCanvasPrefetch('WebPrimary')).resolves.toHaveLength(2);
289
+ });
290
+
291
+ it('resolves to null when the download fails, so the handler can retry', async () => {
292
+ vi.spyOn(FakeFontFace.prototype, 'load').mockRejectedValueOnce(
293
+ new Error('network'),
294
+ );
295
+ prefetchFont({ fontFamily: 'WebFail', fontUrl: 'web.woff2' });
296
+
297
+ await expect(takeCanvasPrefetch('WebFail')).resolves.toBe(null);
298
+ });
299
+
300
+ it('does nothing when the engine has no FontFace', () => {
301
+ (globalThis as unknown as { FontFace: unknown }).FontFace = undefined;
302
+ prefetchFont({ fontFamily: 'NoFontFace', fontUrl: 'web.woff2' });
303
+
304
+ expect(takeCanvasPrefetch('NoFontFace')).toBe(undefined);
305
+ });
306
+
307
+ it('registers prefetched faces with the platform without reloading them', async () => {
308
+ prefetchFont({ fontFamily: 'CanvasHit', fontUrl: 'web.woff2' });
309
+ const createdByPrefetch = FakeFontFace.created.length;
310
+
311
+ const added: FakeFontFace[] = [];
312
+ const stage = {
313
+ platform: { addFont: (f: FakeFontFace) => added.push(f) },
314
+ } as unknown as Stage;
315
+
316
+ await loadCanvasFont(stage, {
317
+ fontFamily: 'CanvasHit',
318
+ fontUrl: 'web.woff2',
319
+ });
320
+
321
+ // No extra FontFace was constructed — the prefetched one was used.
322
+ expect(FakeFontFace.created.length).toBe(createdByPrefetch);
323
+ expect(added).toEqual(FakeFontFace.created);
324
+ expect(isCanvasFontLoaded('CanvasHit')).toBe(true);
325
+ });
326
+
327
+ it('loads normally when there is no prefetch to claim', async () => {
328
+ const added: FakeFontFace[] = [];
329
+ const stage = {
330
+ platform: { addFont: (f: FakeFontFace) => added.push(f) },
331
+ } as unknown as Stage;
332
+
333
+ await loadCanvasFont(stage, {
334
+ fontFamily: 'CanvasMiss',
335
+ fontUrl: 'web.woff2',
336
+ });
337
+
338
+ expect(FakeFontFace.created.map((f) => f.family)).toEqual(['CanvasMiss']);
339
+ expect(isCanvasFontLoaded('CanvasMiss')).toBe(true);
340
+ });
341
+ });
@@ -3,11 +3,14 @@ import {
3
3
  loadFont,
4
4
  waitingForFont,
5
5
  isFontLoaded,
6
+ getFontData,
7
+ canRenderFont,
6
8
  MAX_FONT_LOAD_RETRIES,
7
9
  } from '../SdfFontHandler.js';
8
10
  import { EventEmitter } from '../../../common/EventEmitter.js';
9
11
  import { TextureError, TextureErrorCode } from '../../TextureError.js';
10
12
  import type { Stage } from '../../Stage.js';
13
+ import type { TrProps } from '../TextRenderer.js';
11
14
 
12
15
  // Minimal XHR stand-in: synchronously delivers valid SDF font JSON so loadFont
13
16
  // proceeds to create the atlas texture and attach its event listeners.
@@ -71,7 +74,7 @@ function makeStage(): { stage: Stage; textures: FakeTexture[] } {
71
74
  return { stage, textures };
72
75
  }
73
76
 
74
- const opts = (fontFamily: string) =>
77
+ const opts = (fontFamily: string | string[]) =>
75
78
  ({
76
79
  fontFamily,
77
80
  atlasUrl: 'atlas.png',
@@ -256,3 +259,107 @@ describe('SdfFontHandler loadFont — automatic retry', () => {
256
259
  expect(node.setUpdateType).toHaveBeenCalledTimes(1);
257
260
  });
258
261
  });
262
+
263
+ describe('SdfFontHandler loadFont — multiple names for one font', () => {
264
+ let originalXHR: unknown;
265
+
266
+ beforeEach(() => {
267
+ originalXHR = (globalThis as unknown as { XMLHttpRequest: unknown })
268
+ .XMLHttpRequest;
269
+ (globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
270
+ FakeXHR;
271
+ });
272
+
273
+ afterEach(() => {
274
+ (globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
275
+ originalXHR;
276
+ });
277
+
278
+ it('fetches once and registers every name against the same font data', async () => {
279
+ const { stage, textures } = makeStage();
280
+ const names = ['Roboto', 'Roboto500', 'RobotoMedium'];
281
+
282
+ const promise = loadFont(stage, opts(names));
283
+ await flush();
284
+ textures[0]!.emit('loaded');
285
+ await promise;
286
+
287
+ // A single atlas texture was created for all three names.
288
+ expect(textures.length).toBe(1);
289
+
290
+ // Every name reports loaded and resolves to the identical cache entry.
291
+ const primaryData = getFontData(names[0]!);
292
+ expect(primaryData).not.toBe(undefined);
293
+ for (let i = 0; i < names.length; i++) {
294
+ expect(isFontLoaded(names[i]!)).toBe(true);
295
+ expect(getFontData(names[i]!)).toBe(primaryData);
296
+ }
297
+ });
298
+
299
+ it('reports every name as renderable while the font is still loading', async () => {
300
+ const { stage, textures } = makeStage();
301
+ const names = ['MidLoadPrimary', 'MidLoadAlias'];
302
+
303
+ const promise = loadFont(stage, opts(names));
304
+
305
+ // Mid-load: nothing is in fontCache yet, so this is answered purely by the
306
+ // in-flight markers. An alias that answers `false` here makes the stage
307
+ // resolve a *different* text renderer for the node — silently substituting
308
+ // a Canvas face, or throwing "No compatible text renderer found" when SDF
309
+ // is the only engine. Both names must answer the same.
310
+ for (let i = 0; i < names.length; i++) {
311
+ expect(canRenderFont({ fontFamily: names[i]! } as TrProps)).toBe(true);
312
+ }
313
+ expect(isFontLoaded(names[1]!)).toBe(false);
314
+
315
+ await flush();
316
+ textures[0]!.emit('loaded');
317
+ await promise;
318
+
319
+ for (let i = 0; i < names.length; i++) {
320
+ expect(canRenderFont({ fontFamily: names[i]! } as TrProps)).toBe(true);
321
+ expect(isFontLoaded(names[i]!)).toBe(true);
322
+ }
323
+ });
324
+
325
+ it('dedupes a load requested under an alias onto the in-flight load', async () => {
326
+ const { stage, textures } = makeStage();
327
+
328
+ const first = loadFont(stage, opts(['DedupePrimary', 'DedupeAlias']));
329
+ const second = loadFont(stage, opts('DedupeAlias'));
330
+
331
+ expect(second).toBe(first);
332
+
333
+ await flush();
334
+ // A single atlas fetch served both calls.
335
+ expect(textures.length).toBe(1);
336
+ textures[0]!.emit('loaded');
337
+ await first;
338
+ });
339
+
340
+ it('wakes nodes parked under an alias, not just the primary name', async () => {
341
+ const { stage, textures } = makeStage();
342
+ const names = ['MultiWakePrimary', 'MultiWakeAlias'];
343
+
344
+ const promise = loadFont(stage, opts(names));
345
+
346
+ // Park one node under the primary name and one under the alias.
347
+ const primaryNode = { id: 1, setUpdateType: vi.fn() };
348
+ const aliasNode = { id: 2, setUpdateType: vi.fn() };
349
+ waitingForFont(
350
+ names[0]!,
351
+ primaryNode as unknown as Parameters<typeof waitingForFont>[1],
352
+ );
353
+ waitingForFont(
354
+ names[1]!,
355
+ aliasNode as unknown as Parameters<typeof waitingForFont>[1],
356
+ );
357
+
358
+ await flush();
359
+ textures[0]!.emit('loaded');
360
+ await promise;
361
+
362
+ expect(primaryNode.setUpdateType).toHaveBeenCalledTimes(1);
363
+ expect(aliasNode.setUpdateType).toHaveBeenCalledTimes(1);
364
+ });
365
+ });
@@ -2,26 +2,70 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { ImageTexture } from './ImageTexture.js';
3
3
  import type { CoreTextureManager } from '../CoreTextureManager.js';
4
4
 
5
+ const makeTxManager = (
6
+ imageBitmapSupported: {
7
+ basic: boolean;
8
+ options: boolean;
9
+ full: boolean;
10
+ premultiplyHonored: boolean | null;
11
+ },
12
+ createImageBitmapMock: () => Promise<unknown>,
13
+ ) =>
14
+ ({
15
+ imageBitmapSupported,
16
+ platform: {
17
+ createImageBitmap: createImageBitmapMock,
18
+ },
19
+ } as unknown as CoreTextureManager);
20
+
5
21
  describe('ImageTexture.createImageBitmap', () => {
6
22
  beforeEach(() => {
7
23
  vi.stubGlobal('ImageBitmap', class {});
8
24
  });
9
25
 
10
- it('passes options to createImageBitmap when no crop is requested and options/full are supported', async () => {
26
+ it('requests a premultiplied bitmap when options are supported and the option is honored', async () => {
27
+ const createImageBitmapMock = vi.fn(() =>
28
+ Promise.resolve({ close: () => {} }),
29
+ );
30
+ const txManager = makeTxManager(
31
+ { basic: true, options: true, full: true, premultiplyHonored: true },
32
+ createImageBitmapMock,
33
+ );
34
+
35
+ const props = ImageTexture.resolveDefaults({
36
+ src: 'test.png',
37
+ premultiplyAlpha: true,
38
+ });
39
+ const texture = new ImageTexture(txManager, props);
40
+
41
+ const blob = new Blob([], { type: 'image/png' });
42
+ const result = await texture.createImageBitmap(
43
+ blob,
44
+ true,
45
+ null,
46
+ null,
47
+ null,
48
+ null,
49
+ );
50
+
51
+ expect(createImageBitmapMock).toHaveBeenCalledWith(blob, {
52
+ premultiplyAlpha: 'premultiply',
53
+ colorSpaceConversion: 'none',
54
+ imageOrientation: 'none',
55
+ });
56
+
57
+ // The bitmap is already premultiplied; WebGL must not premultiply again
58
+ expect(result.premultiplyAlpha).toBe(false);
59
+ });
60
+
61
+ it('requests a straight bitmap and defers premultiply to WebGL when the option is not honored', async () => {
11
62
  const createImageBitmapMock = vi.fn(() =>
12
63
  Promise.resolve({ close: () => {} }),
13
64
  );
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;
65
+ const txManager = makeTxManager(
66
+ { basic: true, options: true, full: true, premultiplyHonored: false },
67
+ createImageBitmapMock,
68
+ );
25
69
 
26
70
  const props = ImageTexture.resolveDefaults({
27
71
  src: 'test.png',
@@ -39,14 +83,13 @@ describe('ImageTexture.createImageBitmap', () => {
39
83
  null,
40
84
  );
41
85
 
42
- // It should have called createImageBitmap with the options object
43
86
  expect(createImageBitmapMock).toHaveBeenCalledWith(blob, {
44
87
  premultiplyAlpha: 'none',
45
88
  colorSpaceConversion: 'none',
46
89
  imageOrientation: 'none',
47
90
  });
48
91
 
49
- // Since premultiplyHonored is false, useGlPremultiply should be true
92
+ // Since premultiplyHonored is false, WebGL premultiplies on upload
50
93
  expect(result.premultiplyAlpha).toBe(true);
51
94
  });
52
95
 
@@ -54,17 +97,10 @@ describe('ImageTexture.createImageBitmap', () => {
54
97
  const createImageBitmapMock = vi.fn(() =>
55
98
  Promise.resolve({ close: () => {} }),
56
99
  );
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;
100
+ const txManager = makeTxManager(
101
+ { basic: true, options: false, full: false, premultiplyHonored: null },
102
+ createImageBitmapMock,
103
+ );
68
104
 
69
105
  const props = ImageTexture.resolveDefaults({
70
106
  src: 'test.png',
@@ -85,7 +121,39 @@ describe('ImageTexture.createImageBitmap', () => {
85
121
  // It should have called basic createImageBitmap without options
86
122
  expect(createImageBitmapMock).toHaveBeenCalledWith(blob);
87
123
 
88
- // And premultiplyAlpha should be false (browser default is assumed to premultiply)
124
+ // The browser default is assumed to premultiply; WebGL must not premultiply again
89
125
  expect(result.premultiplyAlpha).toBe(false);
90
126
  });
127
+
128
+ it('defers premultiply to WebGL on basic-only devices when premultiplyAlphaHonored is false', async () => {
129
+ const createImageBitmapMock = vi.fn(() =>
130
+ Promise.resolve({ close: () => {} }),
131
+ );
132
+ const txManager = makeTxManager(
133
+ { basic: true, options: false, full: false, premultiplyHonored: false },
134
+ createImageBitmapMock,
135
+ );
136
+
137
+ const props = ImageTexture.resolveDefaults({
138
+ src: 'test.png',
139
+ premultiplyAlpha: true,
140
+ });
141
+ const texture = new ImageTexture(txManager, props);
142
+
143
+ const blob = new Blob([], { type: 'image/png' });
144
+ const result = await texture.createImageBitmap(
145
+ blob,
146
+ true,
147
+ null,
148
+ null,
149
+ null,
150
+ null,
151
+ );
152
+
153
+ // Still the basic call — no options are supported on these devices
154
+ expect(createImageBitmapMock).toHaveBeenCalledWith(blob);
155
+
156
+ // Device default returns straight alpha; WebGL premultiplies on upload
157
+ expect(result.premultiplyAlpha).toBe(true);
158
+ });
91
159
  });
@@ -225,10 +225,12 @@ export class ImageTexture extends Texture {
225
225
  ) {
226
226
  // basic createImageBitmap without options or crop
227
227
  // this is supported for Chrome v50 to v52/54 that doesn't support options.
228
- // The browser default premultiplies, so WebGL must not premultiply again.
228
+ // The browser default premultiplies, so WebGL must not premultiply again
229
+ // except on devices whose default returns straight alpha
230
+ // (premultiplyAlphaHonored: false); there WebGL premultiplies on upload.
229
231
  return {
230
232
  data: await this.platform.createImageBitmap(blob),
231
- premultiplyAlpha: false,
233
+ premultiplyAlpha: useGlPremultiply,
232
234
  };
233
235
  }
234
236
 
@@ -654,10 +654,14 @@ export type RendererMainSettings = RendererRuntimeSettings & {
654
654
  * ignore it, returning straight (non-premultiplied) alpha. This causes edge
655
655
  * "ghosting" on images with transparency.
656
656
  *
657
- * Set to `'auto'` to detect via a cheap startup probe (one 1×1 texture
658
- * upload + framebuffer readback). Set to a boolean to force the value. Leave
659
- * unset to assume the option is honored the default, which preserves
660
- * existing behavior with no probe overhead.
657
+ * Set to `'auto'` to detect via a cheap startup probe (one 1×1 PNG decode +
658
+ * texture upload + framebuffer readback). On devices that honor the option
659
+ * the vast majority the probe returns `true` and behavior is identical to
660
+ * assuming it honored; only devices that actually ignore it (e.g. the
661
+ * Movistar STB) switch to the WebGL-side premultiply fallback. Set to a
662
+ * boolean to force the value (`false` forces the GL-premultiply fallback;
663
+ * `true` skips it). Leave unset to assume the option is honored — the
664
+ * default, which preserves existing behavior with no probe overhead.
661
665
  *
662
666
  * @defaultValue `true` (assume honored; no probe)
663
667
  */