diff --git a/src/core/platforms/web/WebPlatform.ts b/src/core/platforms/web/WebPlatform.ts index 3731f72c..c0d8c92d 100644 --- a/src/core/platforms/web/WebPlatform.ts +++ b/src/core/platforms/web/WebPlatform.ts @@ -18,7 +18,11 @@ */ import { Platform, type PlatformSettings } from '../Platform.js'; -import { ImageWorkerManager } from './lib/ImageWorker.js'; +import { + ImageWorkerManager, + type ImageWorkerFactory, +} from './lib/ImageWorker.js'; +import { createImageWorker } from './lib/ImageWorkerDefault.js'; import type { Stage } from '../../Stage.js'; import { dataURIToBlob, @@ -37,7 +41,7 @@ import type { GlContextWrapper } from '../GlContextWrapper.js'; * make fontface add not show errors */ interface FontFaceSetWithAdd extends FontFaceSet { - add(font: FontFace): void; + add(font: FontFace): this; } export class WebPlatform extends Platform { @@ -52,10 +56,23 @@ export class WebPlatform extends Platform { this.useImageWorker = numImageWorkers > 0 && this.hasWorker; if (this.useImageWorker === true) { - this.imageWorkerManager = new ImageWorkerManager(numImageWorkers); + this.imageWorkerManager = this.createImageWorkerManager(numImageWorkers); } } + protected createImageWorkerManager( + numImageWorkers: number, + ): ImageWorkerManager { + return new ImageWorkerManager( + numImageWorkers, + this.getImageWorkerFactory(), + ); + } + + protected getImageWorkerFactory(): ImageWorkerFactory { + return createImageWorker; + } + //////////////////////// // Platform-specific methods //////////////////////// @@ -65,7 +82,11 @@ export class WebPlatform extends Platform { } override createContext(): GlContextWrapper { - const gl = createWebGLContext(this.canvas!, this.settings.forceWebGL2); + if (this.canvas === null) { + throw new Error('Canvas has not been created yet.'); + } + + const gl = createWebGLContext(this.canvas, this.settings.forceWebGL2); this.glw = new WebGlContextWrapper(gl); return this.glw; } @@ -159,12 +180,16 @@ export class WebPlatform extends Platform { override fetch(url: string): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); - xhr.responseType = ''; + xhr.responseType = 'blob'; xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { // On most devices like WebOS and Tizen, the file protocol returns 0 while http(s) protocol returns 200 if (xhr.status === 0 || xhr.status === 200) { - resolve(xhr.response); + if (xhr.response instanceof Blob) { + resolve(xhr.response); + } else { + reject(new Error('Expected blob response while loading image.')); + } } else { reject(xhr.statusText); } @@ -234,7 +259,7 @@ export class WebPlatform extends Platform { // fallback to main thread loading let blob: Blob; - if (isBase64Image(src) === true) { + if (isBase64 === true) { blob = dataURIToBlob(src); } else { blob = await this.fetch(absoluteSrc); diff --git a/src/core/platforms/web/WebPlatformChrome50.ts b/src/core/platforms/web/WebPlatformChrome50.ts index 177d89ec..52c59395 100644 --- a/src/core/platforms/web/WebPlatformChrome50.ts +++ b/src/core/platforms/web/WebPlatformChrome50.ts @@ -19,6 +19,8 @@ import { WebPlatform } from './WebPlatform.js'; import type { ImageResponse } from '../../textures/ImageTexture.js'; +import type { ImageWorkerFactory } from './lib/ImageWorker.js'; +import { createImageWorkerNoOptions } from './lib/ImageWorkerNoOptions.js'; /** * Chrome 50 Web Platform implementation with limited createImageBitmap support @@ -33,6 +35,10 @@ import type { ImageResponse } from '../../textures/ImageTexture.js'; * - Image workers can still be used if enabled via settings */ export class WebPlatformChrome50 extends WebPlatform { + protected override getImageWorkerFactory(): ImageWorkerFactory { + return createImageWorkerNoOptions; + } + override async createImage( blob: Blob, premultiplyAlpha: boolean | null, diff --git a/src/core/platforms/web/WebPlatformLegacy.ts b/src/core/platforms/web/WebPlatformLegacy.ts index 00def7ba..1a27f84e 100644 --- a/src/core/platforms/web/WebPlatformLegacy.ts +++ b/src/core/platforms/web/WebPlatformLegacy.ts @@ -20,6 +20,8 @@ import { WebPlatform } from './WebPlatform.js'; import type { PlatformSettings } from '../Platform.js'; import type { ImageResponse } from '../../textures/ImageTexture.js'; +import type { ImageWorkerFactory } from './lib/ImageWorker.js'; +import { createImageWorkerLegacy } from './lib/ImageWorkerLegacy.js'; import { isBase64Image, dataURIToBlob, @@ -37,8 +39,11 @@ import { */ export class WebPlatformLegacy extends WebPlatform { constructor(settings: PlatformSettings = {}) { - // Force image workers to be disabled in legacy mode - super({ ...settings, numImageWorkers: 0 }); + super({ ...settings, numImageWorkers: settings.numImageWorkers ?? 0 }); + } + + protected override getImageWorkerFactory(): ImageWorkerFactory { + return createImageWorkerLegacy; } override async loadImage( @@ -50,6 +55,11 @@ export class WebPlatformLegacy extends WebPlatform { sh?: number | null, ): Promise { const isBase64 = isBase64Image(src); + + if (isBase64 === false && this.settings.numImageWorkers > 0) { + return super.loadImage(src, premultiplyAlpha, sx, sy, sw, sh); + } + const absoluteSrc = convertUrlToAbsolute(src); // For base64 images, use blob conversion diff --git a/src/core/platforms/web/lib/ImageWorker.ts b/src/core/platforms/web/lib/ImageWorker.ts index a11a5e27..f39d29bd 100644 --- a/src/core/platforms/web/lib/ImageWorker.ts +++ b/src/core/platforms/web/lib/ImageWorker.ts @@ -19,12 +19,22 @@ import type { ImageResponse } from '../../../textures/ImageTexture.js'; -type MessageCallback = [(value: any) => void, (reason: any) => void]; +export type ImageWorkerFactory = () => void; + +type MessageCallback = [ + (value: ImageResponse) => void, + (reason: unknown) => void, +]; + +interface ImageWorkerLegacyResponse { + data: Blob; + premultiplyAlpha: boolean | null; +} interface ImageWorkerMessage { id: number; src: string; - data: ImageResponse; + data: ImageResponse | ImageWorkerLegacyResponse; error: string; sx: number | null; sy: number | null; @@ -32,123 +42,45 @@ interface ImageWorkerMessage { sh: number | null; } -/** - * Note that, within the createImageWorker function, we must only use ES5 code to keep it ES5-valid after babelifying, as - * the converted code of this section is converted to a blob and used as the js of the web worker thread. - * - * The createImageWorker function is a web worker that fetches an image from a URL and returns an ImageBitmap object. - * The eslint @typescript rule is disabled for the entire function because the function is converted to a blob and used as the - * js of the web worker thread, so the typescript syntax is not valid in this context. - */ +export class ImageWorkerManager { + imageWorkersEnabled = true; + messageManager: Record = {}; + workers: Worker[] = []; + workerLoad: number[] = []; + nextId = 0; -/* eslint-disable */ -function createImageWorker() { - function hasAlphaChannel(mimeType: string) { - return mimeType.indexOf('image/png') !== -1; + constructor(numImageWorkers: number, workerFactory: ImageWorkerFactory) { + this.workers = this.createWorkers(numImageWorkers, workerFactory); + this.workers.forEach((worker, index) => { + worker.onmessage = (event) => this.handleMessage(event, index); + }); } - function getImage( - src: string, + private isLegacyResponse( + data: ImageResponse | ImageWorkerLegacyResponse, + ): data is ImageWorkerLegacyResponse { + return data.data instanceof Blob; + } + + private createImageFromBlob( + blob: Blob, premultiplyAlpha: boolean | null, - x: number | null, - y: number | null, - width: number | null, - height: number | null, ): Promise { - return new Promise(function (resolve, reject) { - var xhr = new XMLHttpRequest(); - xhr.open('GET', src, true); - xhr.responseType = 'blob'; - - xhr.onload = function () { - // On most devices like WebOS and Tizen, the file protocol returns 0 while http(s) protocol returns 200 - if (xhr.status !== 200 && xhr.status !== 0) { - return reject( - new Error( - `Image loading failed. HTTP status code: ${ - xhr.status || 'N/A' - }. URL: ${src}`, - ), - ); - } + return new Promise((resolve, reject) => { + const objectUrl = URL.createObjectURL(blob); + const image = new Image(); - var blob = xhr.response; - var withAlphaChannel = - premultiplyAlpha !== undefined - ? premultiplyAlpha - : hasAlphaChannel(blob.type); - - // createImageBitmap with crop and options - if (width !== null && height !== null) { - createImageBitmap(blob, x || 0, y || 0, width, height, { - premultiplyAlpha: withAlphaChannel ? 'premultiply' : 'none', - colorSpaceConversion: 'none', - imageOrientation: 'none', - }) - .then(function (data) { - resolve({ data, premultiplyAlpha: premultiplyAlpha }); - }) - .catch(function (error) { - reject(error); - }); - return; - } else { - createImageBitmap(blob, { - premultiplyAlpha: withAlphaChannel ? 'premultiply' : 'none', - colorSpaceConversion: 'none', - imageOrientation: 'none', - }) - .then(function (data) { - resolve({ data, premultiplyAlpha: premultiplyAlpha }); - }) - .catch(function (error) { - reject(error); - }); - } + image.onload = () => { + URL.revokeObjectURL(objectUrl); + resolve({ data: image, premultiplyAlpha }); }; - xhr.onerror = function () { - reject( - new Error('Network error occurred while trying to fetch the image.'), - ); + image.onerror = () => { + URL.revokeObjectURL(objectUrl); + reject(new Error('Image loading failed for legacy worker response.')); }; - xhr.send(); - }); - } - - self.onmessage = (event) => { - var src = event.data.src; - var id = event.data.id; - var premultiplyAlpha = event.data.premultiplyAlpha; - var x = event.data.sx; - var y = event.data.sy; - var width = event.data.sw; - var height = event.data.sh; - - getImage(src, premultiplyAlpha, x, y, width, height) - .then(function (data) { - // @ts-ignore ts has wrong postMessage signature - self.postMessage({ id: id, src: src, data: data }, [data.data]); - }) - .catch(function (error) { - self.postMessage({ id: id, src: src, error: error.message }); - }); - }; -} -/* eslint-enable */ - -export class ImageWorkerManager { - imageWorkersEnabled = true; - messageManager: Record = {}; - workers: Worker[] = []; - workerLoad: number[] = []; - nextId = 0; - - constructor(numImageWorkers: number) { - this.workers = this.createWorkers(numImageWorkers); - this.workers.forEach((worker, index) => { - worker.onmessage = (event) => this.handleMessage(event, index); + image.src = objectUrl; }); } @@ -165,14 +97,21 @@ export class ImageWorkerManager { delete this.messageManager[id]; if (error) { reject(new Error(error)); + } else if (this.isLegacyResponse(data)) { + this.createImageFromBlob(data.data, data.premultiplyAlpha) + .then(resolve) + .catch(reject); } else { resolve(data); } } } - private createWorkers(numWorkers = 1): Worker[] { - let workerCode = `(${createImageWorker.toString()})()`; + private createWorkers( + numWorkers = 1, + workerFactory: ImageWorkerFactory, + ): Worker[] { + let workerCode = `(${workerFactory.toString()})()`; workerCode = workerCode.replace('"use strict";', ''); const blob: Blob = new Blob([workerCode], { @@ -226,9 +165,15 @@ export class ImageWorkerManager { if (nextWorkerIndex !== -1) { const worker = this.workers[nextWorkerIndex]; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.workerLoad[nextWorkerIndex]!++; - worker!.postMessage({ + if (worker === undefined) { + delete this.messageManager[id]; + reject(new Error('No image worker available.')); + return; + } + + this.workerLoad[nextWorkerIndex] = + (this.workerLoad[nextWorkerIndex] ?? 0) + 1; + worker.postMessage({ id, src: src, premultiplyAlpha, diff --git a/src/core/platforms/web/lib/ImageWorkerDefault.ts b/src/core/platforms/web/lib/ImageWorkerDefault.ts new file mode 100644 index 00000000..86c93061 --- /dev/null +++ b/src/core/platforms/web/lib/ImageWorkerDefault.ts @@ -0,0 +1,117 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2026 Comcast Cable Communications Management, LLC. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an AS IS BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Note that, within the createImageWorker function, we must only use ES5 code + * to keep it ES5-valid after babelifying, as the converted code of this section + * is converted to a blob and used as the js of the web worker thread. + */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ +export function createImageWorker() { + function hasAlphaChannel(mimeType: string) { + return mimeType.indexOf('image/png') !== -1; + } + + function getImage( + src: string, + premultiplyAlpha: boolean | null, + x: number | null, + y: number | null, + width: number | null, + height: number | null, + ) { + return new Promise(function (resolve, reject) { + var xhr = new XMLHttpRequest(); + xhr.open('GET', src, true); + xhr.responseType = 'blob'; + + xhr.onload = function () { + if (xhr.status !== 200 && xhr.status !== 0) { + return reject( + new Error( + `Image loading failed. HTTP status code: ${ + xhr.status || 'N/A' + }. URL: ${src}`, + ), + ); + } + + var blob = xhr.response; + var withAlphaChannel = + premultiplyAlpha !== undefined + ? premultiplyAlpha + : hasAlphaChannel(blob.type); + + if (width !== null && height !== null) { + createImageBitmap(blob, x || 0, y || 0, width, height, { + premultiplyAlpha: withAlphaChannel ? 'premultiply' : 'none', + colorSpaceConversion: 'none', + imageOrientation: 'none', + }) + .then(function (data) { + resolve({ data, premultiplyAlpha: premultiplyAlpha }); + }) + .catch(function (error) { + reject(error); + }); + return; + } + + createImageBitmap(blob, { + premultiplyAlpha: withAlphaChannel ? 'premultiply' : 'none', + colorSpaceConversion: 'none', + imageOrientation: 'none', + }) + .then(function (data) { + resolve({ data, premultiplyAlpha: premultiplyAlpha }); + }) + .catch(function (error) { + reject(error); + }); + }; + + xhr.onerror = function () { + reject( + new Error('Network error occurred while trying to fetch the image.'), + ); + }; + + xhr.send(); + }); + } + + self.onmessage = (event) => { + var src = event.data.src; + var id = event.data.id; + var premultiplyAlpha = event.data.premultiplyAlpha; + var x = event.data.sx; + var y = event.data.sy; + var width = event.data.sw; + var height = event.data.sh; + + getImage(src, premultiplyAlpha, x, y, width, height) + .then(function (data) { + // @ts-expect-error ts has wrong postMessage signature + self.postMessage({ id: id, src: src, data: data }, [data.data]); + }) + .catch(function (error) { + self.postMessage({ id: id, src: src, error: error.message }); + }); + }; +} diff --git a/src/core/platforms/web/lib/ImageWorkerLegacy.ts b/src/core/platforms/web/lib/ImageWorkerLegacy.ts new file mode 100644 index 00000000..c3a07e57 --- /dev/null +++ b/src/core/platforms/web/lib/ImageWorkerLegacy.ts @@ -0,0 +1,87 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2026 Comcast Cable Communications Management, LLC. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an AS IS BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unused-vars + */ +export function createImageWorkerLegacy() { + function hasAlphaChannel(mimeType: string) { + return mimeType.indexOf('image/png') !== -1; + } + + function getImage( + src: string, + premultiplyAlpha: boolean | null, + x: number | null, + y: number | null, + width: number | null, + height: number | null, + ): Promise<{ data: Blob; premultiplyAlpha: boolean | null }> { + return new Promise(function (resolve, reject) { + var xhr = new XMLHttpRequest(); + xhr.open('GET', src, true); + xhr.responseType = 'blob'; + + xhr.onload = function () { + if (xhr.status !== 200 && xhr.status !== 0) { + return reject( + new Error( + `Image loading failed. HTTP status code: ${ + xhr.status || 'N/A' + }. URL: ${src}`, + ), + ); + } + + var blob = xhr.response; + var withAlphaChannel = + premultiplyAlpha !== undefined + ? premultiplyAlpha + : hasAlphaChannel(blob.type); + + resolve({ data: blob, premultiplyAlpha: withAlphaChannel }); + }; + + xhr.onerror = function () { + reject( + new Error('Network error occurred while trying to fetch the image.'), + ); + }; + + xhr.send(); + }); + } + + self.onmessage = (event) => { + var src = event.data.src; + var id = event.data.id; + var premultiplyAlpha = event.data.premultiplyAlpha; + var x = event.data.sx; + var y = event.data.sy; + var width = event.data.sw; + var height = event.data.sh; + + getImage(src, premultiplyAlpha, x, y, width, height) + .then(function (data) { + self.postMessage({ id: id, src: src, data: data }); + }) + .catch(function (error) { + self.postMessage({ id: id, src: src, error: error.message }); + }); + }; +} diff --git a/src/core/platforms/web/lib/ImageWorkerNoOptions.ts b/src/core/platforms/web/lib/ImageWorkerNoOptions.ts new file mode 100644 index 00000000..b4b3f505 --- /dev/null +++ b/src/core/platforms/web/lib/ImageWorkerNoOptions.ts @@ -0,0 +1,99 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2026 Comcast Cable Communications Management, LLC. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an AS IS BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Note that, within the createImageWorkerNoOptions function, we must only use + * ES5 code to keep it ES5-valid after babelifying, as the converted code of + * this section is converted to a blob and used as the js of the web worker + * thread. + */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ +export function createImageWorkerNoOptions() { + function hasAlphaChannel(mimeType: string) { + return mimeType.indexOf('image/png') !== -1; + } + + function getImage( + src: string, + premultiplyAlpha: boolean | null, + x: number | null, + y: number | null, + width: number | null, + height: number | null, + ): Promise<{ data: ImageBitmap; premultiplyAlpha: boolean | null }> { + return new Promise(function (resolve, reject) { + var xhr = new XMLHttpRequest(); + xhr.open('GET', src, true); + xhr.responseType = 'blob'; + + xhr.onload = function () { + if (xhr.status !== 200 && xhr.status !== 0) { + return reject( + new Error( + `Image loading failed. HTTP status code: ${ + xhr.status || 'N/A' + }. URL: ${src}`, + ), + ); + } + + var blob = xhr.response; + var withAlphaChannel = + premultiplyAlpha !== undefined + ? premultiplyAlpha + : hasAlphaChannel(blob.type); + + createImageBitmap(blob) + .then(function (data) { + resolve({ data, premultiplyAlpha: withAlphaChannel }); + }) + .catch(function (error) { + reject(error); + }); + }; + + xhr.onerror = function () { + reject( + new Error('Network error occurred while trying to fetch the image.'), + ); + }; + + xhr.send(); + }); + } + + self.onmessage = (event) => { + var src = event.data.src; + var id = event.data.id; + var premultiplyAlpha = event.data.premultiplyAlpha; + var x = event.data.sx; + var y = event.data.sy; + var width = event.data.sw; + var height = event.data.sh; + + getImage(src, premultiplyAlpha, x, y, width, height) + .then(function (data) { + // @ts-expect-error ts has wrong postMessage signature + self.postMessage({ id: id, src: src, data: data }, [data.data]); + }) + .catch(function (error) { + self.postMessage({ id: id, src: src, error: error.message }); + }); + }; +}