diff --git a/src/core/CoreTextureManager.ts b/src/core/CoreTextureManager.ts index 5fd673d..17f1b15 100644 --- a/src/core/CoreTextureManager.ts +++ b/src/core/CoreTextureManager.ts @@ -281,12 +281,21 @@ export class CoreTextureManager extends EventEmitter { ); } else { console.warn( - '[Lightning] Imageworker is 0 or not supported on this browser. Image loading will be slower.', + '[Lightning] Image worker count is 0 or workers are not supported on this browser. Image loading will be slower.', ); } this.initialized = true; this.emit('initialized'); + + // Anything that arrived before initialization completed is now safe to + // process. Without this, queued textures would sit until the next frame + // tick happens to call processSome(). + if (this.uploadTextureQueue.size > 0) { + this.processSome(Infinity).catch((err) => { + console.error('Failed to drain pre-init texture queue:', err); + }); + } } /** @@ -295,6 +304,9 @@ export class CoreTextureManager extends EventEmitter { * @param texture - The texture to upload */ enqueueUploadTexture(texture: Texture): void { + if (texture.state === 'failed' || texture.state === 'freed') { + return; + } this.uploadTextureQueue.add(texture); } @@ -381,7 +393,10 @@ export class CoreTextureManager extends EventEmitter { console.error(`Failed to upload texture:`, err); texture.setState( 'failed', - new TextureError(TextureErrorCode.TEXTURE_DATA_NULL), + new TextureError( + TextureErrorCode.TEXTURE_UPLOAD_FAILED, + err instanceof Error ? err.message : undefined, + ), ); }); return; @@ -432,7 +447,7 @@ export class CoreTextureManager extends EventEmitter { } const coreContext = texture.loadCtxTexture(); - if (coreContext !== null && coreContext.state === 'loaded') { + if (coreContext.state === 'loaded') { texture.setState('loaded'); return; } @@ -468,6 +483,12 @@ export class CoreTextureManager extends EventEmitter { const [texture] = this.uploadTextureQueue; if (!texture) break; this.uploadTextureQueue.delete(texture); + + // Skip textures that were freed or failed between enqueue and now. + if (texture.state === 'failed' || texture.state === 'freed') { + continue; + } + try { if (texture.textureData === null) { await texture.getTextureData(); diff --git a/src/core/TextureError.ts b/src/core/TextureError.ts index 34ae812..422fd24 100644 --- a/src/core/TextureError.ts +++ b/src/core/TextureError.ts @@ -2,6 +2,7 @@ export enum TextureErrorCode { MEMORY_THRESHOLD_EXCEEDED = 'MEMORY_THRESHOLD_EXCEEDED', TEXTURE_DATA_NULL = 'TEXTURE_DATA_NULL', TEXTURE_TYPE_NOT_REGISTERED = 'TEXTURE_TYPE_NOT_REGISTERED', + TEXTURE_UPLOAD_FAILED = 'TEXTURE_UPLOAD_FAILED', } const defaultMessages: Record = { @@ -9,6 +10,7 @@ const defaultMessages: Record = { [TextureErrorCode.TEXTURE_DATA_NULL]: 'Texture data is null', [TextureErrorCode.TEXTURE_TYPE_NOT_REGISTERED]: 'Texture type is not registered', + [TextureErrorCode.TEXTURE_UPLOAD_FAILED]: 'Texture upload failed', }; export class TextureError extends Error { diff --git a/src/core/lib/ImageWorker.ts b/src/core/lib/ImageWorker.ts index 83551d0..de15edc 100644 --- a/src/core/lib/ImageWorker.ts +++ b/src/core/lib/ImageWorker.ts @@ -67,7 +67,7 @@ function createImageWorker() { var blob = xhr.response; var withAlphaChannel = - premultiplyAlpha !== undefined + premultiplyAlpha !== undefined && premultiplyAlpha !== null ? premultiplyAlpha : hasAlphaChannel(blob.type); @@ -83,7 +83,7 @@ function createImageWorker() { imageOrientation: 'none', }) .then(function (data) { - resolve({ data, premultiplyAlpha: premultiplyAlpha }); + resolve({ data: data, premultiplyAlpha: withAlphaChannel }); }) .catch(function (error) { reject(error); @@ -91,13 +91,13 @@ function createImageWorker() { return; } else if ( supportsOptionsCreateImageBitmap === false && - supportsOptionsCreateImageBitmap === false + supportsFullCreateImageBitmap === false ) { // Fallback for browsers that do not support createImageBitmap with options // this is supported for Chrome v50 to v52/54 that doesn't support options createImageBitmap(blob) .then(function (data) { - resolve({ data, premultiplyAlpha: premultiplyAlpha }); + resolve({ data: data, premultiplyAlpha: withAlphaChannel }); }) .catch(function (error) { reject(error); @@ -109,7 +109,7 @@ function createImageWorker() { imageOrientation: 'none', }) .then(function (data) { - resolve({ data, premultiplyAlpha: premultiplyAlpha }); + resolve({ data: data, premultiplyAlpha: withAlphaChannel }); }) .catch(function (error) { reject(error); @@ -156,7 +156,6 @@ function createImageWorker() { /* eslint-enable */ export class ImageWorkerManager { - imageWorkersEnabled = true; messageManager: Record = {}; workers: Worker[] = []; workerLoad: number[] = []; @@ -172,6 +171,8 @@ export class ImageWorkerManager { ); this.workers.forEach((worker, index) => { worker.onmessage = (event) => this.handleMessage(event, index); + worker.onerror = (event) => this.handleWorkerError(event, index); + worker.onmessageerror = (event) => this.handleWorkerError(event, index); }); } @@ -194,6 +195,25 @@ export class ImageWorkerManager { } } + private handleWorkerError(event: Event | ErrorEvent, workerIndex: number) { + const message = + event instanceof ErrorEvent && event.message + ? event.message + : 'Image worker encountered an unrecoverable error'; + + // Reject all pending requests; we cannot map a worker-level crash to a + // specific message id, so fail everything outstanding to avoid hangs. + for (const id in this.messageManager) { + const msg = this.messageManager[id]; + if (msg) { + const [, reject] = msg; + delete this.messageManager[id]; + reject(new Error(message)); + } + } + this.workerLoad[workerIndex] = 0; + } + private createWorkers( numWorkers = 1, createImageBitmapSupport: CreateImageBitmapSupport, @@ -224,19 +244,22 @@ export class ImageWorkerManager { const blob: Blob = new Blob([workerCode], { type: 'application/javascript', }); - const blobURL: string = (self.URL ? URL : webkitURL).createObjectURL(blob); + const urlFactory = self.URL ? URL : webkitURL; + const blobURL: string = urlFactory.createObjectURL(blob); const workers: Worker[] = []; for (let i = 0; i < numWorkers; i++) { workers.push(new Worker(blobURL)); this.workerLoad.push(0); } + // Workers retain the script; the URL itself is no longer needed. + urlFactory.revokeObjectURL(blobURL); return workers; } private getNextWorkerIndex(): number { if (this.workers.length === 0) return -1; - let minLoad = 99; + let minLoad = Infinity; let workerIndex = 0; for (let i = 0; i < this.workers.length; i++) { @@ -264,26 +287,26 @@ export class ImageWorkerManager { ): Promise { return new Promise((resolve, reject) => { try { - if (this.workers) { - const id = this.nextId++; - this.messageManager[id] = [resolve, reject]; - const nextWorkerIndex = this.getNextWorkerIndex(); - - if (nextWorkerIndex !== -1) { - const worker = this.workers[nextWorkerIndex]; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.workerLoad[nextWorkerIndex]!++; - worker!.postMessage({ - id, - src: src, - premultiplyAlpha, - sx, - sy, - sw, - sh, - }); - } + const nextWorkerIndex = this.getNextWorkerIndex(); + if (nextWorkerIndex === -1) { + reject(new Error('No image workers available')); + return; } + + const id = this.nextId++; + this.messageManager[id] = [resolve, reject]; + const worker = this.workers[nextWorkerIndex]; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.workerLoad[nextWorkerIndex]!++; + worker!.postMessage({ + id, + src: src, + premultiplyAlpha, + sx, + sy, + sw, + sh, + }); } catch (error) { reject(error); } diff --git a/src/core/textures/ImageTexture.ts b/src/core/textures/ImageTexture.ts index c7a1820..5845f53 100644 --- a/src/core/textures/ImageTexture.ts +++ b/src/core/textures/ImageTexture.ts @@ -147,11 +147,22 @@ export class ImageTexture extends Texture { data: HTMLImageElement | null; premultiplyAlpha: boolean; }>((resolve, reject) => { + let objectUrl: string | null = null; + + const cleanup = () => { + if (objectUrl !== null) { + URL.revokeObjectURL(objectUrl); + objectUrl = null; + } + }; + img.onload = () => { + cleanup(); resolve({ data: img, premultiplyAlpha: hasAlpha }); }; img.onerror = (err) => { + cleanup(); const errorMessage = err instanceof Error ? err.message @@ -162,7 +173,8 @@ export class ImageTexture extends Texture { }; if (src instanceof Blob) { - img.src = URL.createObjectURL(src); + objectUrl = URL.createObjectURL(src); + img.src = objectUrl; } else { img.src = src; } @@ -218,10 +230,11 @@ export class ImageTexture extends Texture { async loadImage(src: string) { const { premultiplyAlpha, sx, sy, sw, sh } = this.props; + const isBase64 = isBase64Image(src); if (this.txManager.hasCreateImageBitmap === true) { if ( - isBase64Image(src) === false && + isBase64 === false && this.txManager.hasWorker === true && this.txManager.imageWorkerManager !== null ) { @@ -235,15 +248,9 @@ export class ImageTexture extends Texture { ); } - let blob; - - if (isBase64Image(src) === true) { - blob = dataURIToBlob(src); - } else { - blob = await fetchJson(src, 'blob').then( - (response) => response as Blob, - ); - } + const blob = isBase64 + ? dataURIToBlob(src) + : ((await fetchJson(src, 'blob')) as Blob); return this.createImageBitmap(blob, premultiplyAlpha, sx, sy, sw, sh); } @@ -362,11 +369,9 @@ export class ImageTexture extends Texture { }`; if (props.sh !== null && props.sw !== null) { - cacheKey += ','; - cacheKey += props.sx ?? ''; - cacheKey += props.sy ?? ''; - cacheKey += props.sw || ''; - cacheKey += props.sh || ''; + cacheKey += `,${props.sx ?? ''},${props.sy ?? ''},${props.sw || ''},${ + props.sh || '' + }`; } return cacheKey;