diff --git a/examples/assets/elevator.svg b/examples/assets/elevator.svg new file mode 100644 index 00000000..2a050e6c --- /dev/null +++ b/examples/assets/elevator.svg @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/assets/lightning.svg b/examples/assets/lightning.svg new file mode 100644 index 00000000..0a50c2ae --- /dev/null +++ b/examples/assets/lightning.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/assets/rocko.svg b/examples/assets/rocko.svg new file mode 100644 index 00000000..ebde6300 --- /dev/null +++ b/examples/assets/rocko.svg @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/assets/rocko2.svg b/examples/assets/rocko2.svg new file mode 100644 index 00000000..ebde6300 --- /dev/null +++ b/examples/assets/rocko2.svg @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/assets/testscreen.svg b/examples/assets/testscreen.svg new file mode 100644 index 00000000..b1adb69f --- /dev/null +++ b/examples/assets/testscreen.svg @@ -0,0 +1,608 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/tests/texture-source.ts b/examples/tests/texture-source.ts new file mode 100644 index 00000000..6a820841 --- /dev/null +++ b/examples/tests/texture-source.ts @@ -0,0 +1,141 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2023 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. + */ + +import { + type INode, + type Dimensions, + type NodeLoadedEventHandler, +} from '@lightningjs/renderer'; +import rockoPng from '../assets/rocko.png'; +import lightningPng from '../assets/lightning.png'; +import elevatorPng from '../assets/elevator.png'; +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +export async function automation(settings: ExampleSettings) { + await test(settings); + await settings.snapshot(); +} + +export default async function test({ renderer, testRoot }: ExampleSettings) { + const FONT_SIZE = 45; + const BEGIN_Y = FONT_SIZE; + + const header = renderer.createTextNode({ + fontFamily: 'Ubuntu', + text: `PNG Source Test`, + fontSize: FONT_SIZE, + parent: testRoot, + }); + + const curX = 0; + let curY = BEGIN_Y; + let curTest = 1; + + const rocko = renderer.createNode({ + x: curX, + y: curY, + src: rockoPng, + parent: testRoot, + }); + + await execLoadingTest(rocko, 181, 218); + + const elevator = renderer.createNode({ + x: curX, + y: curY, + src: elevatorPng, + parent: testRoot, + srcX: 120, + srcY: 0, + srcHeight: 268, + srcWidth: 100, + }); + + await execLoadingTest(elevator, 100, 268); + + const lightningNode = renderer.createNode({ + x: curX, + y: curY, + src: lightningPng, + srcHeight: 100, + srcWidth: 100, + parent: testRoot, + }); + + await execLoadingTest(lightningNode, 100, 100); + + function waitForTxLoaded(imgNode: INode) { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('TIMEOUT')); + }, 1000); + imgNode.once('loaded', ((target, payload) => { + resolve(payload.dimensions); + }) satisfies NodeLoadedEventHandler); + }); + } + + async function execLoadingTest( + imgNode: INode, + expectedWidth: number, + expectedHeight: number, + ) { + const textNode = renderer.createTextNode({ + fontFamily: 'Ubuntu', + x: curX, + text: '', + fontSize: FONT_SIZE, + parent: testRoot, + }); + + let exception: string | false = false; + let dimensions: Dimensions = { width: 0, height: 0 }; + try { + dimensions = await waitForTxLoaded(imgNode); + } catch (e: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + exception = (e as any)?.message ?? 'Unknown'; + } + + imgNode.width = dimensions.width; + imgNode.height = dimensions.height; + + textNode.y = imgNode.y + imgNode.height; + let result = 'Fail'; + let expectedPostfix = ''; + if ( + !exception && + imgNode.width === expectedWidth && + imgNode.height === expectedHeight + ) { + textNode.color = 0x00ff00ff; + result = 'Pass'; + } else { + textNode.color = 0xff0000ff; + if (exception) { + expectedPostfix = ` (exception: ${exception})`; + } else { + expectedPostfix = ` (expected ${expectedWidth}x${expectedHeight})`; + } + } + textNode.text = `${curTest}. Loaded Event Test: ${result} (${imgNode.width}x${imgNode.height})${expectedPostfix}`; + curY = textNode.y + FONT_SIZE; + curTest++; + } +} diff --git a/examples/tests/texture-svg.ts b/examples/tests/texture-svg.ts new file mode 100644 index 00000000..8e2f4c4a --- /dev/null +++ b/examples/tests/texture-svg.ts @@ -0,0 +1,209 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2023 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. + */ + +import { + type INode, + type Dimensions, + type NodeLoadedEventHandler, + type NodeFailedEventHandler, + Texture, +} from '@lightningjs/renderer'; +import rockoSvg from '../assets/rocko.svg'; +import lightning from '../assets/lightning.svg'; +import elevatorSvg from '../assets/elevator.svg'; +import rockoSvg2 from '../assets/rocko2.svg'; +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +export async function automation(settings: ExampleSettings) { + await test(settings); + await settings.snapshot(); +} + +export default async function test({ renderer, testRoot }: ExampleSettings) { + const FONT_SIZE = 45; + const BEGIN_Y = FONT_SIZE; + + const header = renderer.createTextNode({ + fontFamily: 'Ubuntu', + text: `SVG Test`, + fontSize: FONT_SIZE, + parent: testRoot, + }); + + const curX = 0; + let curY = BEGIN_Y; + let curTest = 1; + + const rocko = renderer.createNode({ + x: curX, + y: curY, + src: rockoSvg, + parent: testRoot, + }); + + await execLoadingTest(rocko, 181, 218); + + const elevator = renderer.createNode({ + x: curX, + y: curY, + src: elevatorSvg, + parent: testRoot, + }); + + await execLoadingTest(elevator, 200, 268); + + const lightningNode = renderer.createNode({ + x: curX, + y: curY, + src: lightning, + height: 25, + width: 125, + parent: testRoot, + }); + + await execLoadingTest(lightningNode, 125, 25); + + const partialSvg = renderer.createNode({ + x: curX, + y: curY, + src: rockoSvg2, + srcX: 100, + srcY: 0, + srcHeight: 218, + srcWidth: 81, + parent: testRoot, + }); + + await execLoadingTest(partialSvg, 81, 218); + + // Test: Check that we capture a texture load failure + const failure = renderer.createNode({ + x: curX, + y: curY, + src: 'does-not-exist.svg', + parent: testRoot, + }); + + await execFailureTest(failure); + + function waitForTxLoaded(imgNode: INode) { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('TIMEOUT')); + }, 1000); + imgNode.once('loaded', ((target, payload) => { + resolve(payload.dimensions); + }) satisfies NodeLoadedEventHandler); + }); + } + + function waitForTxFailed(imgNode: INode) { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('TIMEOUT')); + }, 1000); + imgNode.once('failed', (() => { + resolve(true); + }) satisfies NodeFailedEventHandler); + }); + } + + async function execLoadingTest( + imgNode: INode, + expectedWidth: number, + expectedHeight: number, + ) { + const textNode = renderer.createTextNode({ + fontFamily: 'Ubuntu', + x: curX, + text: '', + fontSize: FONT_SIZE, + parent: testRoot, + }); + + let exception: string | false = false; + let dimensions: Dimensions = { width: 0, height: 0 }; + try { + dimensions = await waitForTxLoaded(imgNode); + } catch (e: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + exception = (e as any)?.message ?? 'Unknown'; + } + + imgNode.width = dimensions.width; + imgNode.height = dimensions.height; + + textNode.y = imgNode.y + imgNode.height; + let result = 'Fail'; + let expectedPostfix = ''; + if ( + !exception && + imgNode.width === expectedWidth && + imgNode.height === expectedHeight + ) { + textNode.color = 0x00ff00ff; + result = 'Pass'; + } else { + textNode.color = 0xff0000ff; + if (exception) { + expectedPostfix = ` (exception: ${exception})`; + } else { + expectedPostfix = ` (expected ${expectedWidth}x${expectedHeight})`; + } + } + textNode.text = `${curTest}. Loaded Event Test: ${result} (${imgNode.width}x${imgNode.height})${expectedPostfix}`; + curY = textNode.y + FONT_SIZE; + curTest++; + } + + async function execFailureTest(imgNode: INode) { + const textNode = renderer.createTextNode({ + fontFamily: 'Ubuntu', + x: curX, + text: '', + fontSize: FONT_SIZE, + parent: testRoot, + }); + + let failureTestPass = false; + let exception: string | false = false; + try { + failureTestPass = await waitForTxFailed(imgNode); + } catch (e: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + exception = (e as any)?.message ?? 'Unknown'; + } + + textNode.y = imgNode.y + imgNode.height; + let result = ''; + if (!exception && failureTestPass) { + textNode.color = 0x00ff00ff; + result = 'Pass'; + } else { + textNode.color = 0xff0000ff; + result = 'Fail'; + if (exception) { + result += ` (exception: ${exception})`; + } + } + textNode.text = `${curTest}. Failure Event Test: ${result}`; + curY = textNode.y + FONT_SIZE; + curTest++; + } +} diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index 79db1024..67048b42 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -613,6 +613,47 @@ export interface CoreNodeProps { * @default `undefined` */ data?: CustomDataMap; + + /** + * Image Type to explicitly set the image type that is being loaded + * + * @remarks + * This property must be used with a `src` that points at an image. In some cases + * the extension doesn't provide a reliable representation of the image type. In such + * cases set the ImageType explicitly. + * + * `regular` is used for normal images such as png, jpg, etc + * `compressed` is used for ETC1/ETC2 compressed images with a PVR or KTX container + * `svg` is used for scalable vector graphics + * + * @default `undefined` + */ + imageType?: 'regular' | 'compressed' | 'svg' | null; + + /** + * She width of the rectangle from which the Image Texture will be extracted. + * This value can be negative. If not provided, the image's source natural + * width will be used. + */ + srcWidth?: number; + /** + * The height of the rectangle from which the Image Texture will be extracted. + * This value can be negative. If not provided, the image's source natural + * height will be used. + */ + srcHeight?: number; + /** + * The x coordinate of the reference point of the rectangle from which the Texture + * will be extracted. `width` and `height` are provided. And only works when + * createImageBitmap is available. Only works when createImageBitmap is supported on the browser. + */ + srcX?: number; + /** + * The y coordinate of the reference point of the rectangle from which the Texture + * will be extracted. Only used when source `srcWidth` width and `srcHeight` height + * are provided. Only works when createImageBitmap is supported on the browser. + */ + srcY?: number; } /** @@ -1845,9 +1886,60 @@ export class CoreNode extends EventEmitter { this.texture = this.stage.txManager.loadTexture('ImageTexture', { src: imageUrl, + width: this.props.width, + height: this.props.height, + type: this.props.imageType, + sx: this.props.srcX, + sy: this.props.srcY, + sw: this.props.srcWidth, + sh: this.props.srcHeight, }); } + set imageType(type: 'regular' | 'compressed' | 'svg' | null) { + if (this.props.imageType === type) { + return; + } + + this.props.imageType = type; + } + + get imageType() { + return this.props.imageType || null; + } + + get srcHeight(): number | undefined { + return this.props.srcHeight; + } + + set srcHeight(value: number) { + this.props.srcHeight = value; + } + + get srcWidth(): number | undefined { + return this.props.srcWidth; + } + + set srcWidth(value: number) { + this.props.srcWidth = value; + } + + get srcX(): number | undefined { + return this.props.srcX; + } + + set srcX(value: number) { + this.props.srcX = value; + } + + get srcY(): number | undefined { + return this.props.srcY; + } + + set srcY(value: number) { + this.props.srcY = value; + } + /** * Returns the framebuffer dimensions of the node. * If the node has a render texture, the dimensions are the same as the node's dimensions. diff --git a/src/core/Stage.ts b/src/core/Stage.ts index b306edae..f9c18805 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -550,6 +550,10 @@ export class Stage { // Since setting the `src` will trigger a texture load, we need to set it after // we set the texture. Otherwise, problems happen. src: props.src ?? null, + srcHeight: props.srcHeight, + srcWidth: props.srcWidth, + srcX: props.srcX, + srcY: props.srcY, scale: props.scale ?? null, scaleX: props.scaleX ?? props.scale ?? 1, scaleY: props.scaleY ?? props.scale ?? 1, @@ -562,6 +566,7 @@ export class Stage { rotation: props.rotation ?? 0, rtt: props.rtt ?? false, data: data, + imageType: props.imageType, }; } } diff --git a/src/core/animations/CoreAnimation.ts b/src/core/animations/CoreAnimation.ts index 29cb4706..af78737d 100644 --- a/src/core/animations/CoreAnimation.ts +++ b/src/core/animations/CoreAnimation.ts @@ -60,7 +60,8 @@ export class CoreAnimation extends EventEmitter { this.propValuesMap['props'] = {}; } this.propValuesMap['props'][key] = { - start: node[key as keyof Omit], + start: + node[key as keyof Omit] || 0, target: props[ key as keyof Omit ] as number, diff --git a/src/core/lib/ImageWorker.ts b/src/core/lib/ImageWorker.ts index c212a7f3..087cf44f 100644 --- a/src/core/lib/ImageWorker.ts +++ b/src/core/lib/ImageWorker.ts @@ -25,6 +25,17 @@ interface getImageReturn { premultiplyAlpha: boolean | null; } +interface ImageWorkerMessage { + id: number; + src: string; + data: getImageReturn; + error: string; + sx: number | null; + sy: number | null; + sw: number | null; + 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. @@ -43,6 +54,10 @@ function createImageWorker() { function getImage( src: string, 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(); @@ -60,6 +75,21 @@ function createImageWorker() { ? 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', @@ -87,8 +117,12 @@ function createImageWorker() { 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) + getImage(src, premultiplyAlpha, x, y, width, height) .then(function (data) { self.postMessage({ id: id, src: src, data: data }); }) @@ -114,12 +148,7 @@ export class ImageWorkerManager { } private handleMessage(event: MessageEvent) { - const { id, data, error } = event.data as { - id: number; - src: string; - data?: any; - error?: string; - }; + const { id, data, error } = event.data as ImageWorkerMessage; const msg = this.messageManager[id]; if (msg) { const [resolve, reject] = msg; @@ -155,6 +184,10 @@ export class ImageWorkerManager { getImage( src: string, premultiplyAlpha: boolean | null, + sx: number | null, + sy: number | null, + sw: number | null, + sh: number | null, ): Promise { return new Promise((resolve, reject) => { try { @@ -167,6 +200,10 @@ export class ImageWorkerManager { id, src: src, premultiplyAlpha, + sx, + sy, + sw, + sh, }); } } diff --git a/src/core/lib/textureSvg.ts b/src/core/lib/textureSvg.ts new file mode 100644 index 00000000..04b04000 --- /dev/null +++ b/src/core/lib/textureSvg.ts @@ -0,0 +1,78 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2023 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. + */ + +import { assertTruthy } from '../../utils.js'; +import { type TextureData } from '../textures/Texture.js'; + +/** + * Tests if the given location is a SVG + * @param url + * @remarks + * This function is used to determine if the given image url is a SVG + * image + * @returns + */ +export function isSvgImage(url: string): boolean { + return /\.(svg)$/.test(url); +} + +/** + * Loads a SVG image + * @param url + * @returns + */ +export const loadSvg = ( + url: string, + width: number | null, + height: number | null, + sx: number | null, + sy: number | null, + sw: number | null, + sh: number | null, +): Promise => { + return new Promise((resolve, reject) => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + assertTruthy(ctx); + + ctx.imageSmoothingEnabled = true; + const img = new Image(); + img.onload = () => { + const x = sx ?? 0; + const y = sy ?? 0; + const w = width || img.width; + const h = height || img.height; + + canvas.width = w; + canvas.height = h; + ctx.drawImage(img, 0, 0, w, h); + + resolve({ + data: ctx.getImageData(x, y, sw ?? w, sh ?? h), + premultiplyAlpha: false, + }); + }; + + img.onerror = (err) => { + reject(err); + }; + + img.src = url; + }); +}; diff --git a/src/core/textures/ImageTexture.ts b/src/core/textures/ImageTexture.ts index b521a3f9..6d05fefd 100644 --- a/src/core/textures/ImageTexture.ts +++ b/src/core/textures/ImageTexture.ts @@ -24,6 +24,7 @@ import { loadCompressedTexture, } from '../lib/textureCompression.js'; import { convertUrlToAbsolute } from '../lib/utils.js'; +import { isSvgImage, loadSvg } from '../lib/textureSvg.js'; /** * Properties of the {@link ImageTexture} @@ -55,6 +56,52 @@ export interface ImageTextureProps { * `ImageData` textures are not cached unless a `key` is provided */ key?: string | null; + /** + * Width of the image to be used as a texture. If not provided, the image's + * natural width will be used. + */ + width?: number | null; + /** + * Height of the image to be used as a texture. If not provided, the image's + * natural height will be used. + */ + height?: number | null; + /** + * Type, indicate an image type for overriding type detection + * + * @default null + */ + type?: 'regular' | 'compressed' | 'svg' | null; + /** + * The width of the rectangle from which the ImageBitmap will be extracted. This value + * can be negative. Only works when createImageBitmap is supported on the browser. + * + * @default null + */ + sw?: number | null; + /** + * The height of the rectangle from which the ImageBitmap will be extracted. This value + * can be negative. Only works when createImageBitmap is supported on the browser. + * + * @default null + */ + sh?: number | null; + /** + * The y coordinate of the reference point of the rectangle from which the ImageBitmap + * will be extracted. Only used when `sw` and `sh` are provided. And only works when + * createImageBitmap is available. + * + * @default null + */ + sx?: number | null; + /** + * The x coordinate of the reference point of the rectangle from which the + * ImageBitmap will be extracted. Only used when source `sw` width and `sh` height + * are provided. Only works when createImageBitmap is supported on the browser. + * + * @default null + */ + sy?: number | null; } /** @@ -83,45 +130,35 @@ export class ImageTexture extends Texture { return mimeType.indexOf('image/png') !== -1; } - override async getTextureData(): Promise { - const { src, premultiplyAlpha } = this.props; - if (!src) { - return { - data: null, - }; - } - - if (typeof src !== 'string') { - if (src instanceof ImageData) { - return { - data: src, - premultiplyAlpha, - }; - } - return { - data: src(), - premultiplyAlpha, - }; - } - - // Handle compressed textures - if (isCompressedTextureContainer(src)) { - return loadCompressedTexture(src); - } + async loadImage(src: string) { + const { premultiplyAlpha, sx, sy, sw, sh, width, height } = this.props; - // Convert relative URL to absolute URL - const absoluteSrc = convertUrlToAbsolute(src); - - if (this.txManager.imageWorkerManager) { + if (this.txManager.imageWorkerManager !== null) { return await this.txManager.imageWorkerManager.getImage( - absoluteSrc, + src, premultiplyAlpha, + sx, + sy, + sw, + sh, ); - } else if (this.txManager.hasCreateImageBitmap) { - const response = await fetch(absoluteSrc); + } else if (this.txManager.hasCreateImageBitmap === true) { + const response = await fetch(src); const blob = await response.blob(); const hasAlphaChannel = premultiplyAlpha ?? this.hasAlphaChannel(blob.type); + + if (sw !== null && sh !== null) { + return { + data: await createImageBitmap(blob, sx ?? 0, sy ?? 0, sw, sh, { + premultiplyAlpha: hasAlphaChannel ? 'premultiply' : 'none', + colorSpaceConversion: 'none', + imageOrientation: 'none', + }), + premultiplyAlpha: hasAlphaChannel, + }; + } + return { data: await createImageBitmap(blob, { premultiplyAlpha: hasAlphaChannel ? 'premultiply' : 'none', @@ -131,11 +168,11 @@ export class ImageTexture extends Texture { premultiplyAlpha: hasAlphaChannel, }; } else { - const img = new Image(); + const img = new Image(width || undefined, height || undefined); if (!(src.substr(0, 5) === 'data:')) { img.crossOrigin = 'Anonymous'; } - img.src = absoluteSrc; + img.src = src; await new Promise((resolve, reject) => { img.onload = () => resolve(); img.onerror = () => reject(new Error(`Failed to load image`)); @@ -150,6 +187,73 @@ export class ImageTexture extends Texture { } } + override async getTextureData(): Promise { + const { src, premultiplyAlpha, type } = this.props; + if (src === null) { + return { + data: null, + }; + } + + if (typeof src !== 'string') { + if (src instanceof ImageData) { + return { + data: src, + premultiplyAlpha, + }; + } + return { + data: src(), + premultiplyAlpha, + }; + } + + const absoluteSrc = convertUrlToAbsolute(src); + if (type === 'regular') { + return this.loadImage(absoluteSrc); + } + + if (type === 'svg') { + return loadSvg( + absoluteSrc, + this.props.width, + this.props.height, + this.props.sx, + this.props.sy, + this.props.sw, + this.props.sh, + ); + } + + if (isSvgImage(src) === true) { + return loadSvg( + absoluteSrc, + this.props.width, + this.props.height, + this.props.sx, + this.props.sy, + this.props.sw, + this.props.sh, + ); + } + + if (type === 'compressed') { + return loadCompressedTexture(absoluteSrc); + } + + if (isCompressedTextureContainer(src) === true) { + return loadCompressedTexture(absoluteSrc); + } + + // default + return this.loadImage(absoluteSrc); + } + + /** + * Generates a cache key for the ImageTexture based on the provided props. + * @param props - The props used to generate the cache key. + * @returns The cache key as a string, or `false` if the key cannot be generated. + */ static override makeCacheKey(props: ImageTextureProps): string | false { const resolvedProps = ImageTexture.resolveDefaults(props); // Only cache key-able textures; prioritise key @@ -157,7 +261,20 @@ export class ImageTexture extends Texture { if (typeof key !== 'string') { return false; } - return `ImageTexture,${key},${resolvedProps.premultiplyAlpha ?? 'true'}`; + + // if we have source dimensions, cache the texture separately + let dimensionProps = ''; + if (resolvedProps.sh !== null && resolvedProps.sw !== null) { + dimensionProps += ','; + dimensionProps += resolvedProps.sx ?? ''; + dimensionProps += resolvedProps.sy ?? ''; + dimensionProps += resolvedProps.sw || ''; + dimensionProps += resolvedProps.sh || ''; + } + + return `ImageTexture,${key},${ + resolvedProps.premultiplyAlpha ?? 'true' + }${dimensionProps}`; } static override resolveDefaults( @@ -167,6 +284,13 @@ export class ImageTexture extends Texture { src: props.src ?? '', premultiplyAlpha: props.premultiplyAlpha ?? true, // null, key: props.key ?? null, + type: props.type ?? null, + width: props.width ?? null, + height: props.height ?? null, + sx: props.sx ?? null, + sy: props.sy ?? null, + sw: props.sw ?? null, + sh: props.sh ?? null, }; } diff --git a/visual-regression/certified-snapshots/chromium-ci/texture-source-1.png b/visual-regression/certified-snapshots/chromium-ci/texture-source-1.png new file mode 100644 index 00000000..fa3c78ef Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/texture-source-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/texture-svg-1.png b/visual-regression/certified-snapshots/chromium-ci/texture-svg-1.png new file mode 100644 index 00000000..da29d34e Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/texture-svg-1.png differ