From e0b23cde318083406d7172f290aa3a5f75143acb Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Fri, 16 Feb 2018 00:44:23 -0500 Subject: [PATCH] Can load saved sb3 files (including sounds and costumes that were modified in the 3.0 editors and saved in the sb3 zip when the project was saved). Tests still need to be fixed. --- package.json | 1 + src/serialization/deserialize-assets.js | 120 ++++++++++++++++++++++++ src/serialization/sb3.js | 22 +++-- src/virtual-machine.js | 21 +++++ 4 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 src/serialization/deserialize-assets.js 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.