diff --git a/package.json b/package.json index a410fb7fd..cc35511c6 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..9032b916a --- /dev/null +++ b/src/serialization/deserialize-assets.js @@ -0,0 +1,134 @@ +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); + } + if (!zip) { + // TODO adding this case to make integration tests pass, need to rethink + // the entire structure of saving/loading here (w.r.t. differences between + // loading from local zip file or from server) + log.error('Zipped assets were not provided.'); + 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); + } + + if (!zip) { + // TODO adding this case to make integration tests pass, need to rethink + // the entire structure of saving/loading here (w.r.t. differences between + // loading from local zip file or from server) + log.error('Zipped assets were not provided.'); + 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/serialization/serialize-assets.js b/src/serialization/serialize-assets.js new file mode 100644 index 000000000..674b29f42 --- /dev/null +++ b/src/serialization/serialize-assets.js @@ -0,0 +1,55 @@ +/** + * Serialize all the assets of the given type ('sounds' or 'costumes') + * in the provided runtime into an array of file descriptors. + * A file descriptor is an object containing the name of the file + * to be written and the contents of the file, the serialized asset. + * @param {Runtime} runtime The runtime with the assets to be serialized + * @param {string} assetType The type of assets to be serialized: 'sounds' | 'costumes' + * @returns {Array} An array of file descriptors for each asset + */ +const serializeAssets = function (runtime, assetType) { + const targets = runtime.targets; + const assetDescs = []; + for (let i = 0; i < targets.length; i++) { + const currTarget = targets[i]; + const currAssets = currTarget.sprite[assetType]; + for (let j = 0; j < currAssets.length; j++) { + const currAsset = currAssets[j]; + const assetId = currAsset.assetId; + const storage = runtime.storage; + const storedAsset = storage.get(assetId); + assetDescs.push({ + fileName: currAsset.md5 ? + currAsset.md5 : `${assetId}.${storedAsset.dataFormat}`, + fileContent: storedAsset.data}); + } + } + return assetDescs; +}; + +/** + * Serialize all the sounds in the provided runtime into an array of file + * descriptors. A file descriptor is an object containing the name of the file + * to be written and the contents of the file, the serialized sound. + * @param {Runtime} runtime The runtime with the sounds to be serialized + * @returns {Array} An array of file descriptors for each sound + */ +const serializeSounds = function (runtime) { + return serializeAssets(runtime, 'sounds'); +}; + +/** + * Serialize all the costumes in the provided runtime into an array of file + * descriptors. A file descriptor is an object containing the name of the file + * to be written and the contents of the file, the serialized costume. + * @param {Runtime} runtime The runtime with the costumes to be serialized + * @returns {Array} An array of file descriptors for each costume + */ +const serializeCostumes = function (runtime) { + return serializeAssets(runtime, 'costumes'); +}; + +module.exports = { + serializeSounds, + serializeCostumes +}; diff --git a/src/virtual-machine.js b/src/virtual-machine.js index d40a2a28b..da9849616 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -1,5 +1,6 @@ const TextEncoder = require('text-encoding').TextEncoder; const EventEmitter = require('events'); +const JSZip = require('jszip'); const centralDispatch = require('./dispatch/central-dispatch'); const ExtensionManager = require('./extension-support/extension-manager'); @@ -15,6 +16,7 @@ const Variable = require('./engine/variable'); const {loadCostume} = require('./import/load-costume.js'); const {loadSound} = require('./import/load-sound.js'); +const {serializeSounds, serializeCostumes} = require('./serialization/serialize-assets'); const RESERVED_NAMES = ['_mouse_', '_stage_', '_edge_', '_myself_', '_random_']; @@ -184,6 +186,28 @@ 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 {Buffer} inputBuffer A buffer representing the project to load. + * @return {!Promise} Promise that resolves after targets are installed. + */ + loadProjectLocal (inputBuffer) { + // TODO need to handle sb2 files as well, and will possibly merge w/ + // above function + return JSZip.loadAsync(inputBuffer) + .then(sb3File => { + sb3File.file('project.json').async('string') + .then(json => { + // TODO error handling for unpacking zip/not finding project.json + 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. @@ -205,8 +229,25 @@ class VirtualMachine extends EventEmitter { * @returns {string} Project in a Scratch 3.0 JSON representation. */ saveProjectSb3 () { - // @todo: Handle other formats, e.g., Scratch 1.4, Scratch 2.0. - return this.toJSON(); + const soundDescs = serializeSounds(this.runtime); + const costumeDescs = serializeCostumes(this.runtime); + const projectJson = this.toJSON(); + + const zip = new JSZip(); + + // Put everything in a zip file + // TODO compression? + zip.file('project.json', projectJson); + for (let i = 0; i < soundDescs.length; i++) { + const currSound = soundDescs[i]; + zip.file(currSound.fileName, currSound.fileContent); + } + for (let i = 0; i < costumeDescs.length; i++) { + const currCostume = costumeDescs[i]; + zip.file(currCostume.fileName, currCostume.fileContent); + } + + return zip.generateAsync({type: 'blob'}); } /** @@ -505,6 +546,10 @@ class VirtualMachine extends EventEmitter { storage.DataFormat.SVG, (new TextEncoder()).encode(svg) ); + // If we're in here, we've edited an svg in the vector editor, + // so the dataFormat should be 'svg' + costume.dataFormat = storage.DataFormat.SVG; + costume.md5 = `${costume.assetId}.${costume.dataFormat}`; this.emitTargetsUpdate(); }