@solidtv/renderer 1.2.10 → 1.3.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 (34) hide show
  1. package/dist/src/core/CoreTextNode.js +3 -1
  2. package/dist/src/core/CoreTextNode.js.map +1 -1
  3. package/dist/src/core/CoreTextureManager.d.ts +10 -0
  4. package/dist/src/core/CoreTextureManager.js +38 -5
  5. package/dist/src/core/CoreTextureManager.js.map +1 -1
  6. package/dist/src/core/Stage.js +3 -1
  7. package/dist/src/core/Stage.js.map +1 -1
  8. package/dist/src/core/lib/ImageWorker.js +22 -6
  9. package/dist/src/core/lib/ImageWorker.js.map +1 -1
  10. package/dist/src/core/lib/textureSvg.js +4 -1
  11. package/dist/src/core/lib/textureSvg.js.map +1 -1
  12. package/dist/src/core/lib/validateImageBitmap.d.ts +18 -0
  13. package/dist/src/core/lib/validateImageBitmap.js +66 -0
  14. package/dist/src/core/lib/validateImageBitmap.js.map +1 -1
  15. package/dist/src/core/renderers/webgl/WebGlCtxTexture.js +5 -1
  16. package/dist/src/core/renderers/webgl/WebGlCtxTexture.js.map +1 -1
  17. package/dist/src/core/textures/ImageTexture.js +21 -7
  18. package/dist/src/core/textures/ImageTexture.js.map +1 -1
  19. package/dist/src/main-api/Renderer.d.ts +18 -0
  20. package/dist/src/main-api/Renderer.js +6 -0
  21. package/dist/src/main-api/Renderer.js.map +1 -1
  22. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  23. package/package.json +1 -1
  24. package/src/core/CoreTextNode.test.ts +145 -0
  25. package/src/core/CoreTextNode.ts +3 -1
  26. package/src/core/CoreTextureManager.ts +61 -8
  27. package/src/core/Stage.ts +3 -0
  28. package/src/core/lib/ImageWorker.ts +30 -6
  29. package/src/core/lib/textureSvg.ts +4 -1
  30. package/src/core/lib/validateImageBitmap.test.ts +119 -0
  31. package/src/core/lib/validateImageBitmap.ts +89 -0
  32. package/src/core/renderers/webgl/WebGlCtxTexture.ts +5 -1
  33. package/src/core/textures/ImageTexture.ts +25 -7
  34. package/src/main-api/Renderer.ts +26 -0
@@ -0,0 +1,145 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { mock } from 'vitest-mock-extended';
3
+ import { CoreTextNode, type CoreTextNodeProps } from './CoreTextNode.js';
4
+ import { Stage } from './Stage.js';
5
+ import { CoreRenderer } from './renderers/CoreRenderer.js';
6
+ import { createBound } from './lib/utils.js';
7
+ import type {
8
+ FontHandler,
9
+ TextRenderer,
10
+ TextRenderInfo,
11
+ } from './text-rendering/TextRenderer.js';
12
+ import type { Texture } from './textures/Texture.js';
13
+
14
+ const defaultProps = (
15
+ overrides?: Partial<CoreTextNodeProps>,
16
+ ): CoreTextNodeProps => ({
17
+ // CoreNodeProps
18
+ alpha: 1,
19
+ autosize: false,
20
+ boundsMargin: null,
21
+ clipping: false,
22
+ color: 0xffffffff,
23
+ colorBl: 0xffffffff,
24
+ colorBottom: 0xffffffff,
25
+ colorBr: 0xffffffff,
26
+ colorLeft: 0xffffffff,
27
+ colorRight: 0xffffffff,
28
+ colorTl: 0xffffffff,
29
+ colorTop: 0xffffffff,
30
+ colorTr: 0xffffffff,
31
+ h: 0,
32
+ mount: 0,
33
+ mountX: 0,
34
+ mountY: 0,
35
+ parent: null,
36
+ pivot: 0,
37
+ pivotX: 0,
38
+ pivotY: 0,
39
+ rotation: 0,
40
+ rtt: false,
41
+ scale: 1,
42
+ scaleX: 1,
43
+ scaleY: 1,
44
+ shader: null,
45
+ src: '',
46
+ texture: null,
47
+ textureOptions: {} as never,
48
+ w: 0,
49
+ x: 0,
50
+ y: 0,
51
+ zIndex: 0,
52
+ preventDestroy: false,
53
+ // TrProps
54
+ text: '',
55
+ textAlign: 'left',
56
+ fontFamily: 'Ubuntu',
57
+ fontStyle: 'normal',
58
+ fontSize: 100,
59
+ maxWidth: 0,
60
+ maxHeight: 0,
61
+ offsetY: 0,
62
+ letterSpacing: 0,
63
+ lineHeight: 0,
64
+ maxLines: 0,
65
+ verticalAlign: 'top',
66
+ overflowSuffix: '...',
67
+ wordBreak: 'break-word',
68
+ contain: 'none',
69
+ // CoreTextNodeProps
70
+ textRendererOverride: null,
71
+ forceLoad: false,
72
+ ...overrides,
73
+ });
74
+
75
+ const makeStage = (texture: Texture) =>
76
+ mock<Stage>({
77
+ strictBound: createBound(0, 0, 200, 200),
78
+ preloadBound: createBound(0, 0, 200, 200),
79
+ defaultTexture: { state: 'loaded' } as never,
80
+ defShaderNode: null as never,
81
+ renderer: mock<CoreRenderer>() as CoreRenderer,
82
+ txManager: { createTexture: vi.fn(() => texture) } as never,
83
+ });
84
+
85
+ const makeCanvasRenderer = (): TextRenderer => {
86
+ const font = mock<FontHandler>({ type: 'canvas' });
87
+ return {
88
+ type: 'canvas',
89
+ font,
90
+ renderText: vi.fn(),
91
+ addQuads: vi.fn(),
92
+ renderQuads: vi.fn(),
93
+ init: vi.fn(),
94
+ } as unknown as TextRenderer;
95
+ };
96
+
97
+ const makeLoadedTexture = () =>
98
+ ({
99
+ state: 'loaded',
100
+ dimensions: { w: 100, h: 50 },
101
+ retryCount: 0,
102
+ maxRetryCount: 0,
103
+ on: vi.fn(),
104
+ off: vi.fn(),
105
+ once: vi.fn(),
106
+ setRenderableOwner: vi.fn(),
107
+ } as unknown as Texture);
108
+
109
+ describe('CoreTextNode (canvas) clearing text', () => {
110
+ it('clears the stale texture when text becomes empty', () => {
111
+ const renderer = makeCanvasRenderer();
112
+ const texture = makeLoadedTexture();
113
+ const stage = makeStage(texture);
114
+
115
+ const node = new CoreTextNode(
116
+ stage,
117
+ defaultProps({ text: 'Hello' }),
118
+ renderer,
119
+ );
120
+
121
+ // Simulate "Hello" having been rendered: canvas returns ImageData and a
122
+ // texture is created/assigned.
123
+ const helloResult: TextRenderInfo = {
124
+ imageData: {} as ImageData,
125
+ width: 100,
126
+ height: 50,
127
+ };
128
+ (
129
+ node as unknown as { handleRenderResult(r: TextRenderInfo): void }
130
+ ).handleRenderResult(helloResult);
131
+
132
+ expect(node.texture).toBe(texture);
133
+
134
+ // Now set text to empty. The canvas renderer returns no imageData.
135
+ const emptyResult: TextRenderInfo = { width: 0, height: 0 };
136
+ (
137
+ node as unknown as { handleRenderResult(r: TextRenderInfo): void }
138
+ ).handleRenderResult(emptyResult);
139
+
140
+ // The previously-rendered texture must be cleared so the old text does not
141
+ // linger and get re-marked renderable by CoreNode.updateIsRenderable.
142
+ expect(node.texture).toBe(null);
143
+ expect(node.isRenderable).toBe(false);
144
+ });
145
+ });
@@ -245,8 +245,10 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
245
245
  // Handle Canvas renderer (uses ImageData)
246
246
  if (textRendererType === 'canvas') {
247
247
  if (result.imageData === undefined) {
248
- // Empty text returns no imageData — mark not renderable and continue
248
+ // Empty text returns no imageData — clear the stale texture so the
249
+ // previous text doesn't linger, then mark not renderable and continue
249
250
  // to update dimensions (w=0, h=0) rather than emitting a failure.
251
+ this.texture = null;
250
252
  this.setRenderable(false);
251
253
  } else {
252
254
  this.texture = this.stage.txManager.createTexture('ImageTexture', {
@@ -10,6 +10,7 @@ import { EventEmitter } from '../common/EventEmitter.js';
10
10
  import type { Stage } from './Stage.js';
11
11
  import {
12
12
  validateCreateImageBitmap,
13
+ detectPremultiplyAlphaHonored,
13
14
  type CreateImageBitmapSupport,
14
15
  } from './lib/validateImageBitmap.js';
15
16
  import type { Platform } from './platforms/Platform.js';
@@ -46,6 +47,9 @@ export interface TextureManagerDebugInfo {
46
47
  export interface TextureManagerSettings {
47
48
  numImageWorkers: number;
48
49
  createImageBitmapSupport: 'auto' | 'basic' | 'options' | 'full';
50
+ // Override for whether createImageBitmap honors premultiplyAlpha:'premultiply'.
51
+ // 'auto' = detect via probe; boolean = force the value and skip the probe.
52
+ premultiplyAlphaHonored: boolean | 'auto';
49
53
  maxRetryCount: number;
50
54
  }
51
55
 
@@ -255,6 +259,7 @@ export class CoreTextureManager extends EventEmitter {
255
259
  basic: false,
256
260
  options: false,
257
261
  full: false,
262
+ premultiplyHonored: null as boolean | null,
258
263
  };
259
264
 
260
265
  hasWorker = !!self.Worker;
@@ -281,8 +286,12 @@ export class CoreTextureManager extends EventEmitter {
281
286
  constructor(stage: Stage, settings: TextureManagerSettings) {
282
287
  super();
283
288
 
284
- const { numImageWorkers, createImageBitmapSupport, maxRetryCount } =
285
- settings;
289
+ const {
290
+ numImageWorkers,
291
+ createImageBitmapSupport,
292
+ premultiplyAlphaHonored,
293
+ maxRetryCount,
294
+ } = settings;
286
295
 
287
296
  this.stage = stage;
288
297
  this.platform = stage.platform;
@@ -292,7 +301,7 @@ export class CoreTextureManager extends EventEmitter {
292
301
  if (createImageBitmapSupport === 'auto') {
293
302
  validateCreateImageBitmap(this.platform)
294
303
  .then((result) => {
295
- this.initialize(result);
304
+ this.resolvePremultiplyAndInit(result, premultiplyAlphaHonored);
296
305
  })
297
306
  .catch(() => {
298
307
  console.warn(
@@ -304,11 +313,15 @@ export class CoreTextureManager extends EventEmitter {
304
313
  this.emit('initialized');
305
314
  });
306
315
  } else {
307
- this.initialize({
308
- basic: createImageBitmapSupport === 'basic',
309
- options: createImageBitmapSupport === 'options',
310
- full: createImageBitmapSupport === 'full',
311
- });
316
+ this.resolvePremultiplyAndInit(
317
+ {
318
+ basic: createImageBitmapSupport === 'basic',
319
+ options: createImageBitmapSupport === 'options',
320
+ full: createImageBitmapSupport === 'full',
321
+ premultiplyHonored: null,
322
+ },
323
+ premultiplyAlphaHonored,
324
+ );
312
325
  }
313
326
 
314
327
  this.registerTextureType('ImageTexture', ImageTexture);
@@ -325,11 +338,51 @@ export class CoreTextureManager extends EventEmitter {
325
338
  this.txConstructors[textureType] = textureClass;
326
339
  }
327
340
 
341
+ /**
342
+ * Resolve `premultiplyHonored` on the support object, then initialize.
343
+ *
344
+ * - boolean override -> use it directly, skip the probe
345
+ * - 'auto' -> run the detection probe (only meaningful when the options/full
346
+ * API exists, since that's the only path that passes the premultiply option)
347
+ */
348
+ private resolvePremultiplyAndInit(
349
+ support: CreateImageBitmapSupport,
350
+ premultiplyAlphaHonored: boolean | 'auto',
351
+ ): void {
352
+ if (premultiplyAlphaHonored !== 'auto') {
353
+ support.premultiplyHonored = premultiplyAlphaHonored;
354
+ this.initialize(support);
355
+ return;
356
+ }
357
+
358
+ if (support.options === false && support.full === false) {
359
+ support.premultiplyHonored = null;
360
+ this.initialize(support);
361
+ return;
362
+ }
363
+
364
+ detectPremultiplyAlphaHonored(this.platform)
365
+ .then((honored) => {
366
+ support.premultiplyHonored = honored;
367
+ this.initialize(support);
368
+ })
369
+ .catch(() => {
370
+ support.premultiplyHonored = null;
371
+ this.initialize(support);
372
+ });
373
+ }
374
+
328
375
  private initialize(support: CreateImageBitmapSupport) {
329
376
  this.hasCreateImageBitmap =
330
377
  support.basic || support.options || support.full;
331
378
  this.imageBitmapSupported = support;
332
379
 
380
+ if (support.premultiplyHonored === false) {
381
+ console.warn(
382
+ '[Lightning] createImageBitmap premultiplyAlpha:"premultiply" is not honored on this device — images may show alpha ghosting. GL-side premultiply fallback recommended.',
383
+ );
384
+ }
385
+
333
386
  if (this.hasCreateImageBitmap === false) {
334
387
  console.warn(
335
388
  '[Lightning] createImageBitmap is not supported on this browser. ImageTexture will be slower.',
package/src/core/Stage.ts CHANGED
@@ -179,6 +179,7 @@ export class Stage {
179
179
  renderEngine,
180
180
  fontEngines,
181
181
  createImageBitmapSupport,
182
+ premultiplyAlphaHonored,
182
183
  platform,
183
184
  maxRetryCount,
184
185
  } = options;
@@ -206,6 +207,8 @@ export class Stage {
206
207
  this.txManager = new CoreTextureManager(this, {
207
208
  numImageWorkers,
208
209
  createImageBitmapSupport,
210
+ // undefined -> true (default: assume honored, no probe)
211
+ premultiplyAlphaHonored: premultiplyAlphaHonored ?? true,
209
212
  maxRetryCount,
210
213
  });
211
214
 
@@ -43,12 +43,14 @@ function createImageWorker() {
43
43
  options: {
44
44
  supportsOptionsCreateImageBitmap: boolean;
45
45
  supportsFullCreateImageBitmap: boolean;
46
+ premultiplyAlphaHonored: boolean;
46
47
  },
47
48
  ): Promise<getImageReturn> {
48
49
  return new Promise(function (resolve, reject) {
49
50
  var supportsOptionsCreateImageBitmap =
50
51
  options.supportsOptionsCreateImageBitmap;
51
52
  var supportsFullCreateImageBitmap = options.supportsFullCreateImageBitmap;
53
+ var premultiplyAlphaHonored = options.premultiplyAlphaHonored;
52
54
  var xhr = new XMLHttpRequest();
53
55
  xhr.open('GET', src, true);
54
56
  xhr.responseType = 'blob';
@@ -71,6 +73,17 @@ function createImageWorker() {
71
73
  ? premultiplyAlpha
72
74
  : hasAlphaChannel(blob.type);
73
75
 
76
+ // When the device ignores the createImageBitmap premultiply option,
77
+ // create a straight ('none') bitmap and let WebGL premultiply on
78
+ // upload. `premultiplyAlpha` in the resolved value means "WebGL should
79
+ // premultiply this source on upload".
80
+ var useGlPremultiply =
81
+ withAlphaChannel === true && premultiplyAlphaHonored === false;
82
+ var bitmapMode: 'premultiply' | 'none' =
83
+ withAlphaChannel === true && useGlPremultiply === false
84
+ ? 'premultiply'
85
+ : 'none';
86
+
74
87
  // createImageBitmap with crop and options
75
88
  if (
76
89
  supportsFullCreateImageBitmap === true &&
@@ -78,12 +91,12 @@ function createImageWorker() {
78
91
  height !== null
79
92
  ) {
80
93
  createImageBitmap(blob, x || 0, y || 0, width, height, {
81
- premultiplyAlpha: withAlphaChannel ? 'premultiply' : 'none',
94
+ premultiplyAlpha: bitmapMode,
82
95
  colorSpaceConversion: 'none',
83
96
  imageOrientation: 'none',
84
97
  })
85
98
  .then(function (data) {
86
- resolve({ data: data, premultiplyAlpha: withAlphaChannel });
99
+ resolve({ data: data, premultiplyAlpha: useGlPremultiply });
87
100
  })
88
101
  .catch(function (error) {
89
102
  reject(error);
@@ -94,22 +107,23 @@ function createImageWorker() {
94
107
  supportsFullCreateImageBitmap === false
95
108
  ) {
96
109
  // Fallback for browsers that do not support createImageBitmap with options
97
- // this is supported for Chrome v50 to v52/54 that doesn't support options
110
+ // this is supported for Chrome v50 to v52/54 that doesn't support options.
111
+ // The browser default premultiplies, so WebGL must not premultiply again.
98
112
  createImageBitmap(blob)
99
113
  .then(function (data) {
100
- resolve({ data: data, premultiplyAlpha: withAlphaChannel });
114
+ resolve({ data: data, premultiplyAlpha: false });
101
115
  })
102
116
  .catch(function (error) {
103
117
  reject(error);
104
118
  });
105
119
  } else {
106
120
  createImageBitmap(blob, {
107
- premultiplyAlpha: withAlphaChannel ? 'premultiply' : 'none',
121
+ premultiplyAlpha: bitmapMode,
108
122
  colorSpaceConversion: 'none',
109
123
  imageOrientation: 'none',
110
124
  })
111
125
  .then(function (data) {
112
- resolve({ data: data, premultiplyAlpha: withAlphaChannel });
126
+ resolve({ data: data, premultiplyAlpha: useGlPremultiply });
113
127
  })
114
128
  .catch(function (error) {
115
129
  reject(error);
@@ -139,10 +153,13 @@ function createImageWorker() {
139
153
  // these will be set to true if the browser supports the createImageBitmap options or full
140
154
  var supportsOptionsCreateImageBitmap = false;
141
155
  var supportsFullCreateImageBitmap = false;
156
+ // set to false when the device is known to ignore the premultiply option
157
+ var premultiplyAlphaHonored = true;
142
158
 
143
159
  getImage(src, premultiplyAlpha, x, y, width, height, {
144
160
  supportsOptionsCreateImageBitmap,
145
161
  supportsFullCreateImageBitmap,
162
+ premultiplyAlphaHonored,
146
163
  })
147
164
  .then(function (data) {
148
165
  // @ts-ignore ts has wrong postMessage signature
@@ -240,6 +257,13 @@ export class ImageWorkerManager {
240
257
  );
241
258
  }
242
259
 
260
+ if (createImageBitmapSupport.premultiplyHonored === false) {
261
+ workerCode = workerCode.replace(
262
+ 'var premultiplyAlphaHonored = true;',
263
+ 'var premultiplyAlphaHonored = false;',
264
+ );
265
+ }
266
+
243
267
  workerCode = workerCode.replace('"use strict";', '');
244
268
  const blob: Blob = new Blob([workerCode], {
245
269
  type: 'application/javascript',
@@ -91,8 +91,11 @@ export const loadSvg = async (
91
91
  }
92
92
  }
93
93
 
94
+ // getImageData returns straight (un-premultiplied) pixels, so WebGL must
95
+ // premultiply this source on upload (unlike the ImageBitmap path above,
96
+ // where the canvas is already premultiplied by createImageBitmap's default).
94
97
  return {
95
98
  data: ctx.getImageData(0, 0, physW, physH),
96
- premultiplyAlpha: false,
99
+ premultiplyAlpha: true,
97
100
  };
98
101
  };
@@ -0,0 +1,119 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { detectPremultiplyAlphaHonored } from './validateImageBitmap.js';
3
+ import type { Platform } from '../platforms/Platform.js';
4
+
5
+ /**
6
+ * Minimal stand-in for the WebGL constants/methods the probe touches. The probe
7
+ * uploads a known straight pixel, reads it back, and infers whether
8
+ * createImageBitmap premultiplied it. `readbackRed` is what readPixels returns
9
+ * for the red channel: ~128 = premultiplied (honored), ~255 = straight (ignored).
10
+ */
11
+ function createFakeGl(readbackRed: number, framebufferComplete = true) {
12
+ return {
13
+ TEXTURE_2D: 0x0de1,
14
+ UNPACK_PREMULTIPLY_ALPHA_WEBGL: 0x9241,
15
+ UNPACK_FLIP_Y_WEBGL: 0x9240,
16
+ RGBA: 0x1908,
17
+ UNSIGNED_BYTE: 0x1401,
18
+ FRAMEBUFFER: 0x8d40,
19
+ COLOR_ATTACHMENT0: 0x8ce0,
20
+ FRAMEBUFFER_COMPLETE: 0x8cd5,
21
+ createTexture: vi.fn(() => ({})),
22
+ bindTexture: vi.fn(),
23
+ pixelStorei: vi.fn(),
24
+ texImage2D: vi.fn(),
25
+ createFramebuffer: vi.fn(() => ({})),
26
+ bindFramebuffer: vi.fn(),
27
+ framebufferTexture2D: vi.fn(),
28
+ checkFramebufferStatus: vi.fn(() => (framebufferComplete ? 0x8cd5 : 0)),
29
+ readPixels: vi.fn(
30
+ (
31
+ _x: number,
32
+ _y: number,
33
+ _w: number,
34
+ _h: number,
35
+ _format: number,
36
+ _type: number,
37
+ px: Uint8Array,
38
+ ) => {
39
+ px[0] = readbackRed;
40
+ px[1] = 0;
41
+ px[2] = 0;
42
+ px[3] = 128;
43
+ },
44
+ ),
45
+ deleteFramebuffer: vi.fn(),
46
+ deleteTexture: vi.fn(),
47
+ };
48
+ }
49
+
50
+ function createPlatform(gl: object | null): Platform {
51
+ const close = vi.fn();
52
+ return {
53
+ createImageBitmap: vi.fn(() => Promise.resolve({ close })),
54
+ createCanvas: vi.fn(() => ({
55
+ width: 0,
56
+ height: 0,
57
+ getContext: vi.fn((type: string) => (type === 'webgl' ? gl : null)),
58
+ })),
59
+ } as unknown as Platform;
60
+ }
61
+
62
+ describe('detectPremultiplyAlphaHonored', () => {
63
+ beforeEach(() => {
64
+ // node test env has no ImageData global; the probe constructs one.
65
+ (globalThis as unknown as { ImageData: unknown }).ImageData = class {
66
+ data: Uint8ClampedArray;
67
+ width: number;
68
+ height: number;
69
+ constructor(data: Uint8ClampedArray, width: number, height: number) {
70
+ this.data = data;
71
+ this.width = width;
72
+ this.height = height;
73
+ }
74
+ };
75
+ });
76
+
77
+ afterEach(() => {
78
+ delete (globalThis as unknown as { ImageData?: unknown }).ImageData;
79
+ });
80
+
81
+ it('returns true when the bitmap reads back premultiplied (~128)', async () => {
82
+ const platform = createPlatform(createFakeGl(128));
83
+ expect(await detectPremultiplyAlphaHonored(platform)).toBe(true);
84
+ });
85
+
86
+ it('returns false when the bitmap reads back straight (~255)', async () => {
87
+ const platform = createPlatform(createFakeGl(255));
88
+ expect(await detectPremultiplyAlphaHonored(platform)).toBe(false);
89
+ });
90
+
91
+ it('returns null when createImageBitmap throws', async () => {
92
+ const platform = {
93
+ createImageBitmap: vi.fn(() => Promise.reject(new Error('unsupported'))),
94
+ createCanvas: vi.fn(),
95
+ } as unknown as Platform;
96
+ expect(await detectPremultiplyAlphaHonored(platform)).toBe(null);
97
+ });
98
+
99
+ it('returns null when no WebGL context is available', async () => {
100
+ const platform = createPlatform(null);
101
+ expect(await detectPremultiplyAlphaHonored(platform)).toBe(null);
102
+ });
103
+
104
+ it('returns null when the framebuffer is incomplete', async () => {
105
+ const platform = createPlatform(createFakeGl(128, false));
106
+ expect(await detectPremultiplyAlphaHonored(platform)).toBe(null);
107
+ });
108
+
109
+ it('disables GL-side premultiply on the probe upload', async () => {
110
+ const gl = createFakeGl(128);
111
+ await detectPremultiplyAlphaHonored(createPlatform(gl));
112
+ // The probe must observe the bitmap's own alpha state, so GL premultiply
113
+ // has to be off during the readback upload.
114
+ expect(gl.pixelStorei).toHaveBeenCalledWith(
115
+ gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL,
116
+ false,
117
+ );
118
+ });
119
+ });
@@ -4,6 +4,11 @@ export interface CreateImageBitmapSupport {
4
4
  basic: boolean; // Supports createImageBitmap(image)
5
5
  options: boolean; // Supports createImageBitmap(image, options)
6
6
  full: boolean; // Supports createImageBitmap(image, sx, sy, sw, sh, options)
7
+ // Whether `premultiplyAlpha: 'premultiply'` is actually HONORED (not just
8
+ // accepted without throwing). null = could not determine. Older Safari/WebKit
9
+ // accepts the option but ignores it, returning straight alpha — the source of
10
+ // the edge-ghosting bug on those devices.
11
+ premultiplyHonored: boolean | null;
7
12
  }
8
13
 
9
14
  export async function validateCreateImageBitmap(
@@ -47,6 +52,7 @@ export async function validateCreateImageBitmap(
47
52
  basic: false,
48
53
  options: false,
49
54
  full: false,
55
+ premultiplyHonored: null,
50
56
  };
51
57
 
52
58
  // Test basic createImageBitmap support
@@ -83,5 +89,88 @@ export async function validateCreateImageBitmap(
83
89
  /* ignore */
84
90
  }
85
91
 
92
+ // premultiplyHonored is resolved separately by the caller (it may be a
93
+ // forced override or an explicit opt-in to the probe), so it is left as its
94
+ // default (null) here.
86
95
  return support;
87
96
  }
97
+
98
+ /**
99
+ * Determine whether `createImageBitmap(..., { premultiplyAlpha: 'premultiply' })`
100
+ * is actually honored by this browser.
101
+ *
102
+ * Strategy: feed a known straight-alpha pixel (255, 0, 0, 128) through
103
+ * createImageBitmap with 'premultiply', upload it to a WebGL texture with
104
+ * GL-side premultiply DISABLED (so we observe the bitmap's own state), then
105
+ * read the raw texel back via a framebuffer.
106
+ *
107
+ * - honored -> red comes back premultiplied (~128)
108
+ * - ignored -> red comes back straight (~255) [older Safari/WebKit]
109
+ *
110
+ * @returns true if honored, false if ignored, null if it couldn't be measured
111
+ * (no WebGL, createImageBitmap from ImageData unsupported, framebuffer
112
+ * incomplete, etc.) — caller should treat null as "unknown".
113
+ */
114
+ export async function detectPremultiplyAlphaHonored(
115
+ platform: Platform,
116
+ ): Promise<boolean | null> {
117
+ let bitmap: ImageBitmap;
118
+ try {
119
+ // Straight (un-premultiplied) RGBA. ImageData is straight-alpha by spec.
120
+ const imageData = new ImageData(
121
+ new Uint8ClampedArray([255, 0, 0, 128]),
122
+ 1,
123
+ 1,
124
+ );
125
+ bitmap = await platform.createImageBitmap(imageData, {
126
+ premultiplyAlpha: 'premultiply',
127
+ colorSpaceConversion: 'none',
128
+ imageOrientation: 'none',
129
+ });
130
+ } catch (e) {
131
+ return null;
132
+ }
133
+
134
+ const canvas = platform.createCanvas();
135
+ canvas.width = 1;
136
+ canvas.height = 1;
137
+ const gl = (canvas.getContext('webgl') ||
138
+ canvas.getContext('experimental-webgl')) as WebGLRenderingContext | null;
139
+ if (gl === null) {
140
+ bitmap.close?.();
141
+ return null;
142
+ }
143
+
144
+ const tex = gl.createTexture();
145
+ gl.bindTexture(gl.TEXTURE_2D, tex);
146
+ // Critical: do NOT let GL premultiply. We want to observe whatever state the
147
+ // bitmap itself is in.
148
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
149
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
150
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap);
151
+
152
+ const fb = gl.createFramebuffer();
153
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
154
+ gl.framebufferTexture2D(
155
+ gl.FRAMEBUFFER,
156
+ gl.COLOR_ATTACHMENT0,
157
+ gl.TEXTURE_2D,
158
+ tex,
159
+ 0,
160
+ );
161
+
162
+ let result: boolean | null = null;
163
+ if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE) {
164
+ const px = new Uint8Array(4);
165
+ gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px);
166
+ // Straight red reads ~255; premultiplied red reads ~128. Split at the
167
+ // midpoint to tolerate rounding/colorspace drift.
168
+ result = px[0]! < 192;
169
+ }
170
+
171
+ gl.deleteFramebuffer(fb);
172
+ gl.deleteTexture(tex);
173
+ bitmap.close?.();
174
+
175
+ return result;
176
+ }
@@ -188,9 +188,13 @@ export class WebGlCtxTexture extends CoreContextTexture {
188
188
  w = tdata.width;
189
189
  h = tdata.height;
190
190
  glw.bindTexture(this._nativeCtxTexture);
191
+ // `premultiplyAlpha` carries the source's GL-upload intent: true when the
192
+ // source pixels are straight and WebGL must premultiply (e.g. a straight
193
+ // bitmap produced when the device ignores the createImageBitmap
194
+ // premultiply option), false when the source is already premultiplied.
191
195
  glw.pixelStorei(
192
196
  glw.UNPACK_PREMULTIPLY_ALPHA_WEBGL,
193
- isImageBitmap ? false : !!textureData.premultiplyAlpha,
197
+ !!textureData.premultiplyAlpha,
194
198
  );
195
199
 
196
200
  glw.texImage2D(0, format, format, glw.UNSIGNED_BYTE, tdata);