@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.
- package/dist/src/core/CoreTextNode.js +3 -1
- package/dist/src/core/CoreTextNode.js.map +1 -1
- package/dist/src/core/CoreTextureManager.d.ts +10 -0
- package/dist/src/core/CoreTextureManager.js +38 -5
- package/dist/src/core/CoreTextureManager.js.map +1 -1
- package/dist/src/core/Stage.js +3 -1
- package/dist/src/core/Stage.js.map +1 -1
- package/dist/src/core/lib/ImageWorker.js +22 -6
- package/dist/src/core/lib/ImageWorker.js.map +1 -1
- package/dist/src/core/lib/textureSvg.js +4 -1
- package/dist/src/core/lib/textureSvg.js.map +1 -1
- package/dist/src/core/lib/validateImageBitmap.d.ts +18 -0
- package/dist/src/core/lib/validateImageBitmap.js +66 -0
- package/dist/src/core/lib/validateImageBitmap.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlCtxTexture.js +5 -1
- package/dist/src/core/renderers/webgl/WebGlCtxTexture.js.map +1 -1
- package/dist/src/core/textures/ImageTexture.js +21 -7
- package/dist/src/core/textures/ImageTexture.js.map +1 -1
- package/dist/src/main-api/Renderer.d.ts +18 -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/package.json +1 -1
- package/src/core/CoreTextNode.test.ts +145 -0
- package/src/core/CoreTextNode.ts +3 -1
- package/src/core/CoreTextureManager.ts +61 -8
- package/src/core/Stage.ts +3 -0
- package/src/core/lib/ImageWorker.ts +30 -6
- package/src/core/lib/textureSvg.ts +4 -1
- package/src/core/lib/validateImageBitmap.test.ts +119 -0
- package/src/core/lib/validateImageBitmap.ts +89 -0
- package/src/core/renderers/webgl/WebGlCtxTexture.ts +5 -1
- package/src/core/textures/ImageTexture.ts +25 -7
- 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
|
+
});
|
package/src/core/CoreTextNode.ts
CHANGED
|
@@ -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 —
|
|
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 {
|
|
285
|
-
|
|
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.
|
|
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.
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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:
|
|
94
|
+
premultiplyAlpha: bitmapMode,
|
|
82
95
|
colorSpaceConversion: 'none',
|
|
83
96
|
imageOrientation: 'none',
|
|
84
97
|
})
|
|
85
98
|
.then(function (data) {
|
|
86
|
-
resolve({ data: data, premultiplyAlpha:
|
|
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:
|
|
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:
|
|
121
|
+
premultiplyAlpha: bitmapMode,
|
|
108
122
|
colorSpaceConversion: 'none',
|
|
109
123
|
imageOrientation: 'none',
|
|
110
124
|
})
|
|
111
125
|
.then(function (data) {
|
|
112
|
-
resolve({ data: data, premultiplyAlpha:
|
|
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:
|
|
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
|
-
|
|
197
|
+
!!textureData.premultiplyAlpha,
|
|
194
198
|
);
|
|
195
199
|
|
|
196
200
|
glw.texImage2D(0, format, format, glw.UNSIGNED_BYTE, tdata);
|