penguinmod-editor-2 / src /lib /file-uploader.js
soiz1's picture
Upload 2891 files
6bcb42f verified
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
};