Spaces:
Runtime error
Runtime error
| const StringUtil = require('../util/string-util'); | |
| const log = require('../util/log'); | |
| const AsyncLimiter = require('../util/async-limiter'); | |
| const {loadSvgString, serializeSvgToString} = require('scratch-svg-renderer'); | |
| const {parseVectorMetadata} = require('../serialization/tw-costume-import-export'); | |
| const loadVector_ = function (costume, runtime, rotationCenter, optVersion) { | |
| return new Promise(resolve => { | |
| let svgString = costume.asset.decodeText(); | |
| // TW: We allow SVGs to specify their rotation center using a special comment. | |
| if (typeof rotationCenter === 'undefined') { | |
| const parsedRotationCenter = parseVectorMetadata(svgString); | |
| if (parsedRotationCenter) { | |
| rotationCenter = parsedRotationCenter; | |
| costume.rotationCenterX = rotationCenter[0]; | |
| costume.rotationCenterY = rotationCenter[1]; | |
| } | |
| } | |
| // SVG Renderer load fixes "quirks" associated with Scratch 2 projects | |
| if (optVersion && optVersion === 2) { | |
| // scratch-svg-renderer fixes syntax that causes loading issues, | |
| // and if optVersion is 2, fixes "quirks" associated with Scratch 2 SVGs, | |
| const fixedSvgString = serializeSvgToString(loadSvgString(svgString, true /* fromVersion2 */)); | |
| // If the string changed, put back into storage | |
| if (svgString !== fixedSvgString) { | |
| svgString = fixedSvgString; | |
| const storage = runtime.storage; | |
| costume.asset.encodeTextData(fixedSvgString, storage.DataFormat.SVG, true); | |
| costume.assetId = costume.asset.assetId; | |
| costume.md5 = `${costume.assetId}.${costume.dataFormat}`; | |
| } | |
| } | |
| // createSVGSkin does the right thing if rotationCenter isn't provided, so it's okay if it's | |
| // undefined here | |
| costume.skinId = runtime.renderer.createSVGSkin(svgString, rotationCenter); | |
| costume.size = runtime.renderer.getSkinSize(costume.skinId); | |
| // Now we should have a rotationCenter even if we didn't before | |
| if (!rotationCenter) { | |
| rotationCenter = runtime.renderer.getSkinRotationCenter(costume.skinId); | |
| costume.rotationCenterX = rotationCenter[0]; | |
| costume.rotationCenterY = rotationCenter[1]; | |
| costume.bitmapResolution = 1; | |
| } | |
| if (runtime.isPackaged) { | |
| costume.asset = null; | |
| } | |
| resolve(costume); | |
| }); | |
| }; | |
| const canvasPool = (function () { | |
| /** | |
| * A pool of canvas objects that can be reused to reduce memory | |
| * allocations. And time spent in those allocations and the later garbage | |
| * collection. | |
| */ | |
| class CanvasPool { | |
| constructor () { | |
| this.pool = []; | |
| this.clearSoon = null; | |
| } | |
| /** | |
| * After a short wait period clear the pool to let the VM collect | |
| * garbage. | |
| */ | |
| clear () { | |
| if (!this.clearSoon) { | |
| this.clearSoon = new Promise(resolve => setTimeout(resolve, 1000)) | |
| .then(() => { | |
| this.pool.length = 0; | |
| this.clearSoon = null; | |
| }); | |
| } | |
| } | |
| /** | |
| * Return a canvas. Create the canvas if the pool is empty. | |
| * @returns {HTMLCanvasElement} A canvas element. | |
| */ | |
| create () { | |
| return this.pool.pop() || document.createElement('canvas'); | |
| } | |
| /** | |
| * Release the canvas to be reused. | |
| * @param {HTMLCanvasElement} canvas A canvas element. | |
| */ | |
| release (canvas) { | |
| this.clear(); | |
| this.pool.push(canvas); | |
| } | |
| } | |
| return new CanvasPool(); | |
| }()); | |
| /** | |
| * @param {string} src URL of image | |
| * @returns {Promise<HTMLImageElement>} | |
| */ | |
| const readAsImageElement = src => new Promise((resolve, reject) => { | |
| const image = new Image(); | |
| image.onload = function () { | |
| resolve(image); | |
| image.onload = null; | |
| image.onerror = null; | |
| }; | |
| image.onerror = function () { | |
| reject(new Error('Costume load failed. Asset could not be read.')); | |
| image.onload = null; | |
| image.onerror = null; | |
| }; | |
| image.src = src; | |
| }); | |
| /** | |
| * @param {Asset} asset scratch-storage asset | |
| * @returns {Promise<HTMLImageElement|ImageBitmap>} | |
| */ | |
| const _persistentReadImage = async asset => { | |
| // Sometimes, when a lot of images are loaded at once, especially in Chrome, reading an image | |
| // can throw an error even on valid images. To mitigate this, we'll retry image reading a few | |
| // time with delays. | |
| let firstError; | |
| for (let i = 0; i < 3; i++) { | |
| try { | |
| if (typeof createImageBitmap === 'function') { | |
| const imageBitmap = await createImageBitmap( | |
| new Blob([asset.data.buffer], {type: asset.assetType.contentType}) | |
| ); | |
| // If we do too many createImageBitmap at the same time, some browsers (Chrome) will | |
| // sometimes resolve with undefined. We limit concurrency so this shouldn't ever | |
| // happen, but if it somehow does, throw an error so it can be retried or so that it | |
| // falls back to scratch's broken costume handling. | |
| if (!imageBitmap) { | |
| throw new Error(`createImageBitmap resolved with ${imageBitmap}`); | |
| } | |
| return imageBitmap; | |
| } | |
| return await readAsImageElement(asset.encodeDataURI()); | |
| } catch (e) { | |
| if (!firstError) { | |
| firstError = e; | |
| } | |
| log.warn(e); | |
| await new Promise(resolve => setTimeout(resolve, Math.random() * 2000)); | |
| } | |
| } | |
| throw firstError; | |
| }; | |
| // Browsers break when we do too many createImageBitmap at the same time. | |
| const readImage = new AsyncLimiter(_persistentReadImage, 25); | |
| /** | |
| * Return a promise to fetch a bitmap from storage and return it as a canvas | |
| * If the costume has bitmapResolution 1, it will be converted to bitmapResolution 2 here (the standard for Scratch 3) | |
| * If the costume has a text layer asset, which is a text part from Scratch 1.4, then this function | |
| * will merge the two image assets. See the issue LLK/scratch-vm#672 for more information. | |
| * @param {!object} costume - the Scratch costume object. | |
| * @param {!Runtime} runtime - Scratch runtime, used to access the v2BitmapAdapter | |
| * @param {?object} rotationCenter - optionally passed in coordinates for the center of rotation for the image. If | |
| * none is given, the rotation center of the costume will be set to the middle of the costume later on. | |
| * @property {number} costume.bitmapResolution - the resolution scale for a bitmap costume. | |
| * @returns {?Promise} - a promise which will resolve to an object {canvas, rotationCenter, assetMatchesBase}, | |
| * or reject on error. | |
| * assetMatchesBase is true if the asset matches the base layer; false if it required adjustment | |
| */ | |
| const fetchBitmapCanvas_ = function (costume, runtime, rotationCenter) { | |
| if (!costume || !costume.asset) { // TODO: We can probably remove this check... | |
| return Promise.reject('Costume load failed. Assets were missing.'); | |
| } | |
| if (!runtime.v2BitmapAdapter) { | |
| return Promise.reject('No V2 Bitmap adapter present.'); | |
| } | |
| return Promise.all([costume.asset, costume.textLayerAsset].map(asset => { | |
| if (!asset) { | |
| return null; | |
| } | |
| return readImage.do(asset); | |
| })) | |
| .then(([baseImageElement, textImageElement]) => { | |
| if (!baseImageElement) { | |
| throw new Error('Loading bitmap costume base failed.'); | |
| } | |
| const scale = costume.bitmapResolution === 1 ? 2 : 1; | |
| let imageOrCanvas; | |
| let canvas; | |
| if (textImageElement) { | |
| canvas = canvasPool.create(); | |
| canvas.width = baseImageElement.width; | |
| canvas.height = baseImageElement.height; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.drawImage(baseImageElement, 0, 0); | |
| ctx.drawImage(textImageElement, 0, 0); | |
| imageOrCanvas = canvas; | |
| } else { | |
| imageOrCanvas = baseImageElement; | |
| } | |
| if (scale !== 1) { | |
| // resize() returns a new canvas. | |
| imageOrCanvas = runtime.v2BitmapAdapter.resize( | |
| imageOrCanvas, | |
| imageOrCanvas.width * scale, | |
| imageOrCanvas.height * scale | |
| ); | |
| // Old canvas is no longer used. | |
| if (canvas) { | |
| canvasPool.release(canvas); | |
| } | |
| } | |
| // This informs TurboWarp/scratch-render that this canvas won't be reused by the canvas pool, | |
| // which helps it optimize memory use. | |
| imageOrCanvas.reusable = false; | |
| // By scaling, we've converted it to bitmap resolution 2 | |
| if (rotationCenter) { | |
| rotationCenter[0] = rotationCenter[0] * scale; | |
| rotationCenter[1] = rotationCenter[1] * scale; | |
| costume.rotationCenterX = rotationCenter[0]; | |
| costume.rotationCenterY = rotationCenter[1]; | |
| } | |
| costume.bitmapResolution = 2; | |
| // Clean up the costume object | |
| delete costume.textLayerMD5; | |
| delete costume.textLayerAsset; | |
| return { | |
| image: imageOrCanvas, | |
| rotationCenter, | |
| // True if the asset matches the base layer; false if it required adjustment | |
| assetMatchesBase: scale === 1 && !textImageElement | |
| }; | |
| }) | |
| .finally(() => { | |
| // Clean up the text layer properties if it fails to load | |
| delete costume.textLayerMD5; | |
| delete costume.textLayerAsset; | |
| }); | |
| }; | |
| const toDataURL = imageOrCanvas => { | |
| if (imageOrCanvas instanceof HTMLCanvasElement) { | |
| return imageOrCanvas.toDataURL(); | |
| } | |
| const canvas = canvasPool.create(); | |
| canvas.width = imageOrCanvas.width; | |
| canvas.height = imageOrCanvas.height; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.drawImage(imageOrCanvas, 0, 0); | |
| const url = canvas.toDataURL(); | |
| canvasPool.release(canvas); | |
| return url; | |
| }; | |
| const loadBitmap_ = function (costume, runtime, _rotationCenter) { | |
| return fetchBitmapCanvas_(costume, runtime, _rotationCenter) | |
| .then(fetched => { | |
| const updateCostumeAsset = function (dataURI) { | |
| if (!runtime.v2BitmapAdapter) { | |
| // TODO: This might be a bad practice since the returned | |
| // promise isn't acted on. If this is something we should be | |
| // creating a rejected promise for we should also catch it | |
| // somewhere and act on that error (like logging). | |
| // | |
| // Return a rejection to stop executing updateCostumeAsset. | |
| return Promise.reject('No V2 Bitmap adapter present.'); | |
| } | |
| const storage = runtime.storage; | |
| costume.asset = storage.createAsset( | |
| storage.AssetType.ImageBitmap, | |
| storage.DataFormat.PNG, | |
| runtime.v2BitmapAdapter.convertDataURIToBinary(dataURI), | |
| null, | |
| true // generate md5 | |
| ); | |
| costume.dataFormat = storage.DataFormat.PNG; | |
| costume.assetId = costume.asset.assetId; | |
| costume.md5 = `${costume.assetId}.${costume.dataFormat}`; | |
| }; | |
| if (!fetched.assetMatchesBase) { | |
| updateCostumeAsset(toDataURL(fetched.image)); | |
| } | |
| return fetched; | |
| }) | |
| .then(({image, rotationCenter}) => { | |
| // createBitmapSkin does the right thing if costume.rotationCenter is undefined. | |
| // That will be the case if you upload a bitmap asset or create one by taking a photo. | |
| let center; | |
| if (rotationCenter) { | |
| // fetchBitmapCanvas will ensure that the costume's bitmap resolution is 2 and its rotation center is | |
| // scaled to match, so it's okay to always divide by 2. | |
| center = [ | |
| rotationCenter[0] / 2, | |
| rotationCenter[1] / 2 | |
| ]; | |
| } | |
| // TODO: costume.bitmapResolution will always be 2 at this point because of fetchBitmapCanvas_, so we don't | |
| // need to pass it in here. | |
| costume.skinId = runtime.renderer.createBitmapSkin(image, costume.bitmapResolution, center); | |
| const renderSize = runtime.renderer.getSkinSize(costume.skinId); | |
| costume.size = [renderSize[0] * 2, renderSize[1] * 2]; // Actual size, since all bitmaps are resolution 2 | |
| if (!rotationCenter) { | |
| rotationCenter = runtime.renderer.getSkinRotationCenter(costume.skinId); | |
| // Actual rotation center, since all bitmaps are resolution 2 | |
| costume.rotationCenterX = rotationCenter[0] * 2; | |
| costume.rotationCenterY = rotationCenter[1] * 2; | |
| costume.bitmapResolution = 2; | |
| } | |
| if (runtime.isPackaged) { | |
| costume.asset = null; | |
| } | |
| return costume; | |
| }); | |
| }; | |
| // Handle all manner of costume errors with a Gray Question Mark (default costume) | |
| // and preserve as much of the original costume data as possible | |
| // Returns a promise of a costume | |
| const handleCostumeLoadError = function (costume, runtime) { | |
| // Keep track of the old asset information until we're done loading the default costume | |
| const oldAsset = costume.asset; // could be null | |
| const oldAssetId = costume.assetId; | |
| const oldRotationX = costume.rotationCenterX; | |
| const oldRotationY = costume.rotationCenterY; | |
| const oldBitmapResolution = costume.bitmapResolution; | |
| const oldDataFormat = costume.dataFormat; | |
| const AssetType = runtime.storage.AssetType; | |
| const isVector = costume.dataFormat === AssetType.ImageVector.runtimeFormat; | |
| // Use default asset if original fails to load | |
| costume.assetId = isVector ? | |
| runtime.storage.defaultAssetId.ImageVector : | |
| runtime.storage.defaultAssetId.ImageBitmap; | |
| costume.asset = runtime.storage.get(costume.assetId); | |
| costume.md5 = `${costume.assetId}.${costume.asset.dataFormat}`; | |
| const defaultCostumePromise = (isVector) ? | |
| loadVector_(costume, runtime) : loadBitmap_(costume, runtime); | |
| return defaultCostumePromise.then(loadedCostume => { | |
| loadedCostume.broken = {}; | |
| loadedCostume.broken.assetId = oldAssetId; | |
| loadedCostume.broken.md5 = `${oldAssetId}.${oldDataFormat}`; | |
| // Should be null if we got here because the costume was missing | |
| loadedCostume.broken.asset = oldAsset; | |
| loadedCostume.broken.dataFormat = oldDataFormat; | |
| loadedCostume.broken.rotationCenterX = oldRotationX; | |
| loadedCostume.broken.rotationCenterY = oldRotationY; | |
| loadedCostume.broken.bitmapResolution = oldBitmapResolution; | |
| return loadedCostume; | |
| }); | |
| }; | |
| /** | |
| * Initialize a costume from an asset asynchronously. | |
| * Do not call this unless there is a renderer attached. | |
| * @param {!object} costume - the Scratch costume object. | |
| * @property {int} skinId - the ID of the costume's render skin, once installed. | |
| * @property {number} rotationCenterX - the X component of the costume's origin. | |
| * @property {number} rotationCenterY - the Y component of the costume's origin. | |
| * @property {number} [bitmapResolution] - the resolution scale for a bitmap costume. | |
| * @property {!Asset} costume.asset - the asset of the costume loaded from storage. | |
| * @param {!Runtime} runtime - Scratch runtime, used to access the storage module. | |
| * @param {?int} optVersion - Version of Scratch that the costume comes from. If this is set | |
| * to 2, scratch 3 will perform an upgrade step to handle quirks in SVGs from Scratch 2.0. | |
| * @returns {?Promise} - a promise which will resolve after skinId is set, or null on error. | |
| */ | |
| const loadCostumeFromAsset = function (costume, runtime, optVersion) { | |
| costume.assetId = costume.asset.assetId; | |
| const renderer = runtime.renderer; | |
| if (!renderer) { | |
| log.warn('No rendering module present; cannot load costume: ', costume.name); | |
| return Promise.resolve(costume); | |
| } | |
| const AssetType = runtime.storage.AssetType; | |
| let rotationCenter; | |
| // Use provided rotation center and resolution if they are defined. Bitmap resolution | |
| // should only ever be 1 or 2. | |
| if (typeof costume.rotationCenterX === 'number' && !isNaN(costume.rotationCenterX) && | |
| typeof costume.rotationCenterY === 'number' && !isNaN(costume.rotationCenterY)) { | |
| rotationCenter = [costume.rotationCenterX, costume.rotationCenterY]; | |
| } | |
| if (costume.asset.assetType.runtimeFormat === AssetType.ImageVector.runtimeFormat) { | |
| return loadVector_(costume, runtime, rotationCenter, optVersion) | |
| .catch(error => { | |
| log.warn(`Error loading vector image: ${error}`); | |
| return handleCostumeLoadError(costume, runtime); | |
| }); | |
| } | |
| return loadBitmap_(costume, runtime, rotationCenter, optVersion) | |
| .catch(error => { | |
| log.warn(`Error loading bitmap image: ${error}`); | |
| return handleCostumeLoadError(costume, runtime); | |
| }); | |
| }; | |
| /** | |
| * Load a costume's asset into memory asynchronously. | |
| * Do not call this unless there is a renderer attached. | |
| * @param {!string} md5ext - the MD5 and extension of the costume to be loaded. | |
| * @param {!object} costume - the Scratch costume object. | |
| * @property {int} skinId - the ID of the costume's render skin, once installed. | |
| * @property {number} rotationCenterX - the X component of the costume's origin. | |
| * @property {number} rotationCenterY - the Y component of the costume's origin. | |
| * @property {number} [bitmapResolution] - the resolution scale for a bitmap costume. | |
| * @param {!Runtime} runtime - Scratch runtime, used to access the storage module. | |
| * @param {?int} optVersion - Version of Scratch that the costume comes from. If this is set | |
| * to 2, scratch 3 will perform an upgrade step to handle quirks in SVGs from Scratch 2.0. | |
| * @returns {?Promise} - a promise which will resolve after skinId is set, or null on error. | |
| */ | |
| const loadCostume = function (md5ext, costume, runtime, optVersion) { | |
| const idParts = StringUtil.splitFirst(md5ext, '.'); | |
| const md5 = idParts[0]; | |
| const ext = idParts[1].toLowerCase(); | |
| costume.dataFormat = ext; | |
| if (costume.asset) { | |
| // Costume comes with asset. It could be coming from image upload, drag and drop, or file | |
| return loadCostumeFromAsset(costume, runtime, optVersion); | |
| } | |
| // Need to load the costume from storage. The server should have a reference to this md5. | |
| if (!runtime.storage) { | |
| log.warn('No storage module present; cannot load costume asset: ', md5ext); | |
| return Promise.resolve(costume); | |
| } | |
| if (!runtime.storage.defaultAssetId) { | |
| log.warn(`No default assets found`); | |
| return Promise.resolve(costume); | |
| } | |
| const AssetType = runtime.storage.AssetType; | |
| const assetType = (ext === 'svg') ? AssetType.ImageVector : AssetType.ImageBitmap; | |
| const costumePromise = runtime.storage.load(assetType, md5, ext); | |
| let textLayerPromise; | |
| if (costume.textLayerMD5) { | |
| textLayerPromise = runtime.storage.load(AssetType.ImageBitmap, costume.textLayerMD5, 'png'); | |
| } else { | |
| textLayerPromise = Promise.resolve(null); | |
| } | |
| return Promise.all([costumePromise, textLayerPromise]) | |
| .then(assetArray => { | |
| if (assetArray[0]) { | |
| costume.asset = assetArray[0]; | |
| } else { | |
| return handleCostumeLoadError(costume, runtime); | |
| } | |
| if (assetArray[1]) { | |
| costume.textLayerAsset = assetArray[1]; | |
| } | |
| return loadCostumeFromAsset(costume, runtime, optVersion); | |
| }) | |
| .catch(error => { | |
| // Handle case where storage.load rejects with errors | |
| // instead of resolving null | |
| log.warn('Error loading costume: ', error); | |
| return handleCostumeLoadError(costume, runtime); | |
| }); | |
| }; | |
| module.exports = { | |
| loadCostume, | |
| loadCostumeFromAsset | |
| }; | |