import {BitmapAdapter, sanitizeSvg} from 'scratch-svg-renderer'; import randomizeSpritePosition from './randomize-sprite-position.js'; import bmpConverter from './bmp-converter'; import gifDecoder from './gif-decoder'; import fixSVG from './tw-svg-fixer'; import convertAudioToWav from './tw-convert-audio-wav.js'; /** * Extract the file name given a string of the form fileName + ext * @param {string} nameExt File name + extension (e.g. 'my_image.png') * @return {string} The name without the extension, or the full name if * there was no '.' in the string (e.g. 'my_image') */ const extractFileName = function (nameExt) { // There could be multiple dots, but get the stuff before the first . const nameParts = nameExt.split('.', 1); // we only care about the first . return nameParts[0]; }; /** * Handle a file upload given the input element that contains the file, * and a function to handle loading the file. * @param {Input} fileInput The element that contains the file being loaded * @param {Function} onload The function that handles loading the file * @param {Function} onerror The function that handles any error loading the file */ const handleFileUpload = function (fileInput, onload, onerror) { const readFile = (i, files) => { if (i === files.length) { // Reset the file input value now that we have everything we need // so that the user can upload the same sound multiple times if // they choose fileInput.value = null; return; } const file = files[i]; const reader = new FileReader(); reader.onload = () => { const fileType = file.type; const fileName = extractFileName(file.name); onload(reader.result, fileType, fileName, i, files.length); readFile(i + 1, files); }; reader.onerror = onerror; reader.readAsArrayBuffer(file); }; readFile(0, fileInput.files); }; /** * @typedef VMAsset * @property {string} name The user-readable name of this asset - This will * automatically get translated to a fresh name if this one already exists in the * scope of this vm asset (e.g. if a sound already exists with the same name for * the same target) * @property {string} dataFormat The data format of this asset, typically * the extension to be used for that particular asset, e.g. 'svg' for vector images * @property {string} md5 The md5 hash of the asset data, followed by '.'' and dataFormat * @property {string} The md5 hash of the asset data // TODO remove duplication.... */ /** * Create an asset (costume, sound) with storage and return an object representation * of the asset to track in the VM. * @param {ScratchStorage} storage The storage to cache the asset in * @param {AssetType} assetType A ScratchStorage AssetType indicating what kind of * asset this is. * @param {string} dataFormat The format of this data (typically the file extension) * @param {UInt8Array} data The asset data buffer * @return {VMAsset} An object representing this asset and relevant information * which can be used to look up the data in storage */ const createVMAsset = function (storage, assetType, dataFormat, data) { const asset = storage.createAsset( assetType, dataFormat, data, null, true // generate md5 ); return { name: null, // Needs to be set by caller dataFormat: dataFormat, asset: asset, md5: `${asset.assetId}.${dataFormat}`, assetId: asset.assetId }; }; /** * Handles loading a costume or a backdrop using the provided, context-relevant information. * @param {ArrayBuffer | string} fileData The costume data to load (this can be a base64 string * iff the image is a bitmap) * @param {string} fileType The MIME type of this file * @param {VM} vm The ScratchStorage instance to cache the costume data * @param {Function} handleCostume The function to execute on the costume object returned after * caching this costume in storage - This function should be responsible for * adding the costume to the VM and handling other UI flow that should come after adding the costume * @param {Function} handleError The function to execute if there is an error parsing the costume */ const costumeUpload = function (fileData, fileType, vm, handleCostume, handleError = () => {}) { const storage = vm.runtime.storage; let costumeFormat = null; let assetType = null; switch (fileType) { case 'image/svg+xml': { // run svg bytes through scratch-svg-renderer's sanitization code fileData = sanitizeSvg.sanitizeByteStream(fileData); costumeFormat = storage.DataFormat.SVG; assetType = storage.AssetType.ImageVector; fileData = fixSVG(fileData); break; } case 'image/jpeg': { costumeFormat = storage.DataFormat.JPG; assetType = storage.AssetType.ImageBitmap; break; } case 'image/bmp': { // Convert .bmp files to .png to compress them. .bmps are completely uncompressed, // and would otherwise take up a lot of storage space and take much longer to upload and download. bmpConverter(fileData).then(dataUrl => { costumeUpload(dataUrl, 'image/png', vm, handleCostume); }); return; // Return early because we're triggering another proper costumeUpload } case 'image/png': { costumeFormat = storage.DataFormat.PNG; assetType = storage.AssetType.ImageBitmap; break; } case 'image/webp': { // Scratch does not natively support webp, so convert to png // see image/bmp logic above bmpConverter(fileData, 'image/webp').then(dataUrl => { costumeUpload(dataUrl, 'image/png', vm, handleCostume); }); return; } case 'image/gif': { let costumes = []; gifDecoder(fileData, (frameNumber, dataUrl, numFrames) => { costumeUpload(dataUrl, 'image/png', vm, costumes_ => { costumes = costumes.concat(costumes_); if (frameNumber === numFrames - 1) { handleCostume(costumes); } }, handleError); }); return; // Abandon this load, do not try to load gif itself } default: handleError(`Encountered unexpected file type: ${fileType}`); return; } const bitmapAdapter = new BitmapAdapter(); if (bitmapAdapter.setStageSize) { const width = vm.runtime.stageWidth; const height = vm.runtime.stageHeight; bitmapAdapter.setStageSize(width, height); } const addCostumeFromBuffer = function (dataBuffer) { const vmCostume = createVMAsset( storage, assetType, costumeFormat, dataBuffer ); handleCostume([vmCostume]); }; if (costumeFormat === storage.DataFormat.SVG) { // Must pass in file data as a Uint8Array, // passing in an array buffer causes the sprite/costume // thumbnails to not display because the data URI for the costume // is invalid addCostumeFromBuffer(new Uint8Array(fileData)); } else { // otherwise it's a bitmap bitmapAdapter.importBitmap(fileData, fileType).then(addCostumeFromBuffer) .catch(handleError); } }; /** * Handles loading a sound using the provided, context-relevant information. * @param {ArrayBuffer} fileData The sound data to load * @param {string} fileType The MIME type of this file; This function will exit * early if the fileType is unexpected. * @param {ScratchStorage} storage The ScratchStorage instance to cache the sound data * @param {Function} handleSound The function to execute on the sound object of type VMAsset * This function should be responsible for adding the sound to the VM * as well as handling other UI flow that should come after adding the sound * @param {Function} handleError The function to execute if there is an error parsing the sound */ const soundUpload = function (fileData, fileType, storage, handleSound, handleError) { let soundFormat; switch (fileType) { case 'audio/mp3': case 'audio/mpeg': { soundFormat = storage.DataFormat.MP3; break; } case 'audio/wav': case 'audio/wave': case 'audio/x-wav': case 'audio/x-pn-wav': { soundFormat = storage.DataFormat.WAV; break; } default: convertAudioToWav(fileData) .then(fixed => { soundUpload(fixed, 'audio/wav', storage, handleSound, handleError); }) .catch(handleError); return; } const vmSound = createVMAsset( storage, storage.AssetType.Sound, soundFormat, new Uint8Array(fileData)); handleSound(vmSound); }; /** * Handles loading a sound using the provided, context-relevant information. * @param {ArrayBuffer} fileData The sound data to load * @param {string} fileType The MIME type of this file. * @param {ScratchStorage} storage The ScratchStorage instance to cache the sound data * @param {Function} handleFile The function to execute on the sound object of type VMAsset * This function should be responsible for adding the sound to the VM * as well as handling other UI flow that should come after adding the sound * @param {Function} handleError The function to execute if there is an error parsing the sound */ const externalFileUpload = function (fileData, fileType, storage, handleFile, handleError) { // TODO: we should handle TXT and JSON differently const vmFile = createVMAsset( storage, storage.AssetType.ExternalFile, storage.DataFormat.TXT, new Uint8Array(fileData)); handleFile(vmFile); }; const spriteUpload = function (fileData, fileType, spriteName, vm, handleSprite, handleError = () => {}) { switch (fileType) { case '': case 'application/zip': { // We think this is a .sprite2 or .sprite3 file handleSprite(new Uint8Array(fileData)); return; } case 'image/svg+xml': case 'image/png': case 'image/bmp': case 'image/jpeg': case 'image/webp': case 'image/gif': { // Make a sprite from an image by making it a costume first costumeUpload(fileData, fileType, vm, vmCostumes => { vmCostumes.forEach((costume, i) => { costume.name = `${spriteName}${i ? i + 1 : ''}`; }); const newSprite = { name: spriteName, isStage: false, x: 0, // x/y will be randomized below y: 0, visible: true, size: 100, rotationStyle: 'all around', direction: 90, draggable: false, currentCostume: 0, blocks: {}, variables: {}, costumes: vmCostumes, sounds: [] // TODO are all of these necessary? }; randomizeSpritePosition(newSprite); // TODO probably just want sprite upload to handle this object directly handleSprite(JSON.stringify(newSprite)); }, handleError); return; } default: { handleError(`Encountered unexpected file type: ${fileType}`); return; } } }; export { handleFileUpload, costumeUpload, soundUpload, spriteUpload, externalFileUpload };