Spaces:
Running
Running
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 <input/> 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; | |
} | |
case 'audio/ogg': { | |
soundFormat = storage.DataFormat.OGG; | |
break; | |
} | |
case 'audio/x-flac': | |
case 'audio/flac': { | |
soundFormat = storage.DataFormat.FLAC; | |
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 | |
}; | |