From 1169f5badd507355b456c0d0f5d0a416ceccb70d Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Fri, 9 Feb 2018 11:16:25 -0500 Subject: [PATCH 01/10] Hooking up vm to scratch-parser for sb2 validation. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index a410fb7fd..6a86f2901 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "scratch-parser": "latest", "scratch-render": "latest", "scratch-storage": "^0.4.0", + "scratch-parser": "latest", "script-loader": "0.7.2", "socket.io-client": "2.0.4", "stats.js": "^0.17.0", From 525a8c2ce7f8277aa3e848be5fcc1f14d6684d1d Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Tue, 13 Feb 2018 10:48:33 -0500 Subject: [PATCH 02/10] Serializing sounds and costumes into file descriptors upon 'saveProjectSb3'. --- src/serialization/serialize-assets.js | 55 +++++++++++++++++++++++++++ src/virtual-machine.js | 10 ++++- 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 src/serialization/serialize-assets.js diff --git a/src/serialization/serialize-assets.js b/src/serialization/serialize-assets.js new file mode 100644 index 000000000..0177d2189 --- /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 asset = storage.get(assetId); + assetDescs.push({ + fileName: assetType === 'sound' ? + currAsset.md5 : `${assetId}.${currAsset.dataFormat}`, + fileContent: asset.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..be4b5c551 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -15,6 +15,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_']; @@ -206,7 +207,14 @@ class VirtualMachine extends EventEmitter { */ 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); + + return { + projectJson: this.toJSON(), + sounds: soundDescs, + costumes: costumeDescs + }; } /** From 52c77d9fb070aa5fee2d5c5faa3a68ce05e4f906 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Tue, 13 Feb 2018 15:27:40 -0500 Subject: [PATCH 03/10] Updating asset serialization code to get data format from stored asset rather than vm state. Storage seems to be more up to date than vm in the case of editing the blank backdrop, which is stored as a png instead of an svg. --- src/serialization/serialize-assets.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/serialization/serialize-assets.js b/src/serialization/serialize-assets.js index 0177d2189..d001baa0e 100644 --- a/src/serialization/serialize-assets.js +++ b/src/serialization/serialize-assets.js @@ -17,11 +17,11 @@ const serializeAssets = function (runtime, assetType) { const currAsset = currAssets[j]; const assetId = currAsset.assetId; const storage = runtime.storage; - const asset = storage.get(assetId); + const storedAsset = storage.get(assetId); assetDescs.push({ fileName: assetType === 'sound' ? - currAsset.md5 : `${assetId}.${currAsset.dataFormat}`, - fileContent: asset.data}); + currAsset.md5 : `${assetId}.${storedAsset.dataFormat}`, + fileContent: storedAsset.data}); } } return assetDescs; From 089df0a50f0b92508638e87aeb9ad33c6bf7ca95 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Wed, 14 Feb 2018 16:52:41 -0500 Subject: [PATCH 04/10] Sounds get updated in storage and vm runtime when they are updated in the sound editor. The updateSvg function ensures that the costume that was edited has an updated dataFormat property in the vm runtime. This is specifically for when pngs get edited in the vector editor. Their file format should then get updated to svg. --- src/virtual-machine.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/virtual-machine.js b/src/virtual-machine.js index be4b5c551..919208984 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -513,6 +513,9 @@ 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; this.emitTargetsUpdate(); } From e0b23cde318083406d7172f290aa3a5f75143acb Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Fri, 16 Feb 2018 00:44:23 -0500 Subject: [PATCH 05/10] 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. From 605ce4b16d739cc63f8cbc7bc76dd242c8eb4b44 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Thu, 22 Feb 2018 16:46:24 -0500 Subject: [PATCH 06/10] Configuring scratch-vm to use changes made to scratch-parser. --- package.json | 4 +++- src/serialization/deserialize-assets.js | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 7bd434f60..730888c9a 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,9 @@ "watch": "webpack --progress --colors --watch", "version": "json -f package.json -I -e \"this.repository.sha = '$(git log -n1 --pretty=format:%H)'\"" }, + "dependencies": { + "scratch-parser": "LLK/scratch-parser#save-load" + }, "devDependencies": { "adm-zip": "0.4.7", "babel-core": "^6.24.1", @@ -59,7 +62,6 @@ "scratch-parser": "latest", "scratch-render": "latest", "scratch-storage": "^0.4.0", - "scratch-parser": "latest", "script-loader": "0.7.2", "socket.io-client": "2.0.4", "stats.js": "^0.17.0", diff --git a/src/serialization/deserialize-assets.js b/src/serialization/deserialize-assets.js index ec1399d5b..9032b916a 100644 --- a/src/serialization/deserialize-assets.js +++ b/src/serialization/deserialize-assets.js @@ -27,7 +27,13 @@ const deserializeSound = function (sound, runtime, zip) { // 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.`); @@ -82,6 +88,14 @@ const deserializeCostume = function (costume, runtime, zip) { 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.`); From 9d8602db931fbd7d24bd01121cfc78069e351bd4 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Mon, 5 Mar 2018 09:59:47 -0500 Subject: [PATCH 07/10] Removing specific save-load branch dependency now that scratch-parser has been updated. --- package.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/package.json b/package.json index 730888c9a..cc35511c6 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,6 @@ "watch": "webpack --progress --colors --watch", "version": "json -f package.json -I -e \"this.repository.sha = '$(git log -n1 --pretty=format:%H)'\"" }, - "dependencies": { - "scratch-parser": "LLK/scratch-parser#save-load" - }, "devDependencies": { "adm-zip": "0.4.7", "babel-core": "^6.24.1", From c0afcd2ab3fa60afd7d0a6a9c43ac27fe3028e16 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Mon, 5 Mar 2018 15:16:00 -0500 Subject: [PATCH 08/10] Ensure that md5 and assetId stay in sync. --- src/serialization/serialize-assets.js | 2 +- src/virtual-machine.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/serialization/serialize-assets.js b/src/serialization/serialize-assets.js index d001baa0e..674b29f42 100644 --- a/src/serialization/serialize-assets.js +++ b/src/serialization/serialize-assets.js @@ -19,7 +19,7 @@ const serializeAssets = function (runtime, assetType) { const storage = runtime.storage; const storedAsset = storage.get(assetId); assetDescs.push({ - fileName: assetType === 'sound' ? + fileName: currAsset.md5 ? currAsset.md5 : `${assetId}.${storedAsset.dataFormat}`, fileContent: storedAsset.data}); } diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 797a68f87..6d97dfe46 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -537,6 +537,7 @@ class VirtualMachine extends EventEmitter { // 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(); } From d6e248aab82347953bca0fcceb0879219228946d Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Mon, 5 Mar 2018 17:20:36 -0500 Subject: [PATCH 09/10] VM should do the heavy lifting of saving project to a zip file, and return a promise of the zip to gui. --- src/virtual-machine.js | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 6d97dfe46..07aa159b4 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'); @@ -227,15 +228,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. const soundDescs = serializeSounds(this.runtime); const costumeDescs = serializeCostumes(this.runtime); + const projectJson = this.toJSON(); - return { - projectJson: this.toJSON(), - sounds: soundDescs, - costumes: costumeDescs - }; + 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'}); } /** From d59a4ffd55ace2aff631765d499fb7dd5f7f2b8b Mon Sep 17 00:00:00 2001 From: kchadha Date: Thu, 8 Mar 2018 10:25:58 -0500 Subject: [PATCH 10/10] VM receives input buffer representing project to load and unpacks it as zip. This functionality will eventually move to storage. --- src/virtual-machine.js | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 07aa159b4..da9849616 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -189,21 +189,22 @@ class VirtualMachine extends EventEmitter { /** * 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. + * @param {Buffer} inputBuffer A buffer representing the project to load. * @return {!Promise} Promise that resolves after targets are installed. */ - loadProjectLocal (sb3File) { + loadProjectLocal (inputBuffer) { // 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)); + 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)); + }); }); }