diff --git a/package.json b/package.json index 6a86f2901..7bd434f60 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "immutable": "3.8.1", "in-publish": "^2.0.0", "json": "^9.0.4", + "jszip": "^3.1.5", "lodash.defaultsdeep": "4.6.0", "minilog": "3.1.0", "nets": "3.2.0", diff --git a/src/serialization/deserialize-assets.js b/src/serialization/deserialize-assets.js new file mode 100644 index 000000000..ec1399d5b --- /dev/null +++ b/src/serialization/deserialize-assets.js @@ -0,0 +1,120 @@ +const JSZip = require('jszip'); +const log = require('../util/log'); + +/** + * Deserializes sound from file into storage cache so that it can + * be loaded into the runtime. + * @param {object} sound Descriptor for sound from sb3 file + * @param {Runtime} runtime The runtime containing the storage to cache the sounds in + * @param {JSZip} zip The zip containing the sound file being described by `sound` + * @return {Promise} Promise that resolves after the described sound has been stored + * into the runtime storage cache, the sound was already stored, or an error has + * occurred. + */ +const deserializeSound = function (sound, runtime, zip) { + const fileName = sound.md5; // The md5 property has the full file name + const storage = runtime.storage; + if (!storage) { + log.error('No storage module present; cannot load sound asset: ', fileName); + return Promise.resolve(null); + } + + const assetId = sound.assetId; + + // TODO Is there a faster way to check that this asset + // has already been initialized? + if (storage.get(assetId)) { + // This sound has already been cached. + return Promise.resolve(null); + } + + const soundFile = zip.file(fileName); + if (!soundFile) { + log.error(`Could not find sound file associated with the ${sound.name} sound.`); + return Promise.resolve(null); + } + let dataFormat = null; + if (sound.dataFormat.toLowerCase() === 'wav') { + dataFormat = storage.DataFormat.WAV; + } + if (!JSZip.support.uint8array) { + log.error('JSZip uint8array is not supported in this browser.'); + return Promise.resolve(null); + } + + return soundFile.async('uint8array').then(data => { + storage.builtinHelper.cache( + storage.AssetType.Sound, + dataFormat, + data, + assetId + ); + }); +}; + +/** + * Deserializes costume from file into storage cache so that it can + * be loaded into the runtime. + * @param {object} costume Descriptor for costume from sb3 file + * @param {Runtime} runtime The runtime containing the storage to cache the costumes in + * @param {JSZip} zip The zip containing the costume file being described by `costume` + * @return {Promise} Promise that resolves after the described costume has been stored + * into the runtime storage cache, the costume was already stored, or an error has + * occurred. + */ +const deserializeCostume = function (costume, runtime, zip) { + const storage = runtime.storage; + const assetId = costume.assetId; + const fileName = costume.md5 ? + costume.md5 : + `${assetId}.${costume.dataFormat}`; // The md5 property has the full file name + + if (!storage) { + log.error('No storage module present; cannot load costume asset: ', fileName); + return Promise.resolve(null); + } + + + // TODO Is there a faster way to check that this asset + // has already been initialized? + if (storage.get(assetId)) { + // This costume has already been cached. + return Promise.resolve(null); + } + + const costumeFile = zip.file(fileName); + if (!costumeFile) { + log.error(`Could not find costume file associated with the ${costume.name} costume.`); + return Promise.resolve(null); + } + let dataFormat = null; + let assetType = null; + const costumeFormat = costume.dataFormat.toLowerCase(); + if (costumeFormat === 'svg') { + dataFormat = storage.DataFormat.SVG; + assetType = storage.AssetType.ImageVector; + } else if (costumeFormat === 'png') { + dataFormat = storage.DataFormat.PNG; + assetType = storage.AssetType.ImageBitmap; + } else { + log.error(`Unexpected file format for costume: ${costumeFormat}`); + } + if (!JSZip.support.uint8array) { + log.error('JSZip uint8array is not supported in this browser.'); + return Promise.resolve(null); + } + + return costumeFile.async('uint8array').then(data => { + storage.builtinHelper.cache( + assetType, + dataFormat, + data, + assetId + ); + }); +}; + +module.exports = { + deserializeSound, + deserializeCostume +}; diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index 9e8bcbf4e..22c7e9346 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -11,6 +11,7 @@ const Variable = require('../engine/variable'); const {loadCostume} = require('../import/load-costume.js'); const {loadSound} = require('../import/load-sound.js'); +const {deserializeCostume, deserializeSound} = require('./deserialize-assets.js'); /** * @typedef {object} ImportedProject @@ -53,9 +54,10 @@ const serialize = function (runtime) { * @param {!object} object From-JSON "Scratch object:" sprite, stage, watcher. * @param {!Runtime} runtime Runtime object to load all structures into. * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. + * @param {JSZip} zip Sb3 file describing this project (to load assets from) * @return {!Promise.} Promise for the target created (stage or sprite), or null for unsupported objects. */ -const parseScratchObject = function (object, runtime, extensions) { +const parseScratchObject = function (object, runtime, extensions, zip) { if (!object.hasOwnProperty('name')) { // Watcher/monitor - skip this object until those are implemented in VM. // @todo @@ -99,13 +101,17 @@ const parseScratchObject = function (object, runtime, extensions) { (costumeSource.assetType && costumeSource.assetType.runtimeFormat) || // older format 'png'; // if all else fails, guess that it might be a PNG const costumeMd5 = `${costumeSource.assetId}.${dataFormat}`; - return loadCostume(costumeMd5, costume, runtime); + costume.md5 = costumeMd5; + return deserializeCostume(costumeSource, runtime, zip) + .then(() => loadCostume(costumeMd5, costume, runtime)); + // Only attempt to load the costume after the deserialization + // process has been completed }); // Sounds from JSON const soundPromises = (object.sounds || []).map(soundSource => { const sound = { format: soundSource.format, - fileUrl: soundSource.fileUrl, + // fileUrl: soundSource.fileUrl, rate: soundSource.rate, sampleCount: soundSource.sampleCount, soundID: soundSource.soundID, @@ -113,7 +119,10 @@ const parseScratchObject = function (object, runtime, extensions) { md5: soundSource.md5, data: null }; - return loadSound(sound, runtime); + return deserializeSound(soundSource, runtime, zip) + .then(() => loadSound(sound, runtime)); + // Only attempt to load the sound after the deserialization + // process has been completed. }); // Create the first clone, and load its run-state from JSON. const target = sprite.createClone(); @@ -169,15 +178,16 @@ const parseScratchObject = function (object, runtime, extensions) { * TODO: parse extension info (also, design extension info storage...) * @param {object} json - JSON representation of a VM runtime. * @param {Runtime} runtime - Runtime instance + * @param {JSZip} zip - Sb3 file describing this project (to load assets from) * @returns {Promise.} Promise that resolves to the list of targets after the project is deserialized */ -const deserialize = function (json, runtime) { +const deserialize = function (json, runtime, zip) { const extensions = { extensionIDs: new Set(), extensionURLs: new Map() }; return Promise.all( - (json.targets || []).map(target => parseScratchObject(target, runtime, extensions)) + (json.targets || []).map(target => parseScratchObject(target, runtime, extensions, zip)) ).then(targets => ({ targets, extensions diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 919208984..797a68f87 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -185,6 +185,27 @@ class VirtualMachine extends EventEmitter { return this.fromJSON(json); } + /** + * Load a project from a Scratch 3.0 sb3 file containing a project json + * and all of the sound and costume files. + * @param {JSZip} sb3File The sb3 file representing the project to load. + * @return {!Promise} Promise that resolves after targets are installed. + */ + loadProjectLocal (sb3File) { + // TODO need to handle sb2 files as well, and will possibly merge w/ + // above function + return sb3File.file('project.json').async('string') + .then(json => { + // TODO look at promise documentation to do this on success, + // but something else on error + + json = JSON.parse(json); // TODO catch errors here (validation) + return sb3.deserialize(json, this.runtime, sb3File) + .then(({targets, extensions}) => + this.installTargets(targets, extensions, true)); + }); + } + /** * Load a project from the Scratch web site, by ID. * @param {string} id - the ID of the project to download, as a string.