Merge pull request #964 from kchadha/save-load

Preliminary Save and Load Work
This commit is contained in:
kchadha 2018-03-09 09:34:44 -05:00 committed by GitHub
commit adfbeddcab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 253 additions and 8 deletions

View file

@ -49,6 +49,7 @@
"immutable": "3.8.1", "immutable": "3.8.1",
"in-publish": "^2.0.0", "in-publish": "^2.0.0",
"json": "^9.0.4", "json": "^9.0.4",
"jszip": "^3.1.5",
"lodash.defaultsdeep": "4.6.0", "lodash.defaultsdeep": "4.6.0",
"minilog": "3.1.0", "minilog": "3.1.0",
"nets": "3.2.0", "nets": "3.2.0",

View file

@ -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
};

View file

@ -11,6 +11,7 @@ const Variable = require('../engine/variable');
const {loadCostume} = require('../import/load-costume.js'); const {loadCostume} = require('../import/load-costume.js');
const {loadSound} = require('../import/load-sound.js'); const {loadSound} = require('../import/load-sound.js');
const {deserializeCostume, deserializeSound} = require('./deserialize-assets.js');
/** /**
* @typedef {object} ImportedProject * @typedef {object} ImportedProject
@ -53,9 +54,10 @@ const serialize = function (runtime) {
* @param {!object} object From-JSON "Scratch object:" sprite, stage, watcher. * @param {!object} object From-JSON "Scratch object:" sprite, stage, watcher.
* @param {!Runtime} runtime Runtime object to load all structures into. * @param {!Runtime} runtime Runtime object to load all structures into.
* @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. * @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.<Target>} Promise for the target created (stage or sprite), or null for unsupported objects. * @return {!Promise.<Target>} 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')) { if (!object.hasOwnProperty('name')) {
// Watcher/monitor - skip this object until those are implemented in VM. // Watcher/monitor - skip this object until those are implemented in VM.
// @todo // @todo
@ -99,13 +101,17 @@ const parseScratchObject = function (object, runtime, extensions) {
(costumeSource.assetType && costumeSource.assetType.runtimeFormat) || // older format (costumeSource.assetType && costumeSource.assetType.runtimeFormat) || // older format
'png'; // if all else fails, guess that it might be a PNG 'png'; // if all else fails, guess that it might be a PNG
const costumeMd5 = `${costumeSource.assetId}.${dataFormat}`; 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 // Sounds from JSON
const soundPromises = (object.sounds || []).map(soundSource => { const soundPromises = (object.sounds || []).map(soundSource => {
const sound = { const sound = {
format: soundSource.format, format: soundSource.format,
fileUrl: soundSource.fileUrl, // fileUrl: soundSource.fileUrl,
rate: soundSource.rate, rate: soundSource.rate,
sampleCount: soundSource.sampleCount, sampleCount: soundSource.sampleCount,
soundID: soundSource.soundID, soundID: soundSource.soundID,
@ -113,7 +119,10 @@ const parseScratchObject = function (object, runtime, extensions) {
md5: soundSource.md5, md5: soundSource.md5,
data: null 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. // Create the first clone, and load its run-state from JSON.
const target = sprite.createClone(); const target = sprite.createClone();
@ -169,15 +178,16 @@ const parseScratchObject = function (object, runtime, extensions) {
* TODO: parse extension info (also, design extension info storage...) * TODO: parse extension info (also, design extension info storage...)
* @param {object} json - JSON representation of a VM runtime. * @param {object} json - JSON representation of a VM runtime.
* @param {Runtime} runtime - Runtime instance * @param {Runtime} runtime - Runtime instance
* @param {JSZip} zip - Sb3 file describing this project (to load assets from)
* @returns {Promise.<ImportedProject>} Promise that resolves to the list of targets after the project is deserialized * @returns {Promise.<ImportedProject>} 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 = { const extensions = {
extensionIDs: new Set(), extensionIDs: new Set(),
extensionURLs: new Map() extensionURLs: new Map()
}; };
return Promise.all( return Promise.all(
(json.targets || []).map(target => parseScratchObject(target, runtime, extensions)) (json.targets || []).map(target => parseScratchObject(target, runtime, extensions, zip))
).then(targets => ({ ).then(targets => ({
targets, targets,
extensions extensions

View file

@ -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<object>} 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<object>} 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<object>} An array of file descriptors for each costume
*/
const serializeCostumes = function (runtime) {
return serializeAssets(runtime, 'costumes');
};
module.exports = {
serializeSounds,
serializeCostumes
};

View file

@ -1,5 +1,6 @@
const TextEncoder = require('text-encoding').TextEncoder; const TextEncoder = require('text-encoding').TextEncoder;
const EventEmitter = require('events'); const EventEmitter = require('events');
const JSZip = require('jszip');
const centralDispatch = require('./dispatch/central-dispatch'); const centralDispatch = require('./dispatch/central-dispatch');
const ExtensionManager = require('./extension-support/extension-manager'); const ExtensionManager = require('./extension-support/extension-manager');
@ -15,6 +16,7 @@ const Variable = require('./engine/variable');
const {loadCostume} = require('./import/load-costume.js'); const {loadCostume} = require('./import/load-costume.js');
const {loadSound} = require('./import/load-sound.js'); const {loadSound} = require('./import/load-sound.js');
const {serializeSounds, serializeCostumes} = require('./serialization/serialize-assets');
const RESERVED_NAMES = ['_mouse_', '_stage_', '_edge_', '_myself_', '_random_']; const RESERVED_NAMES = ['_mouse_', '_stage_', '_edge_', '_myself_', '_random_'];
@ -184,6 +186,28 @@ class VirtualMachine extends EventEmitter {
return this.fromJSON(json); 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. * Load a project from the Scratch web site, by ID.
* @param {string} id - the ID of the project to download, as a string. * @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. * @returns {string} Project in a Scratch 3.0 JSON representation.
*/ */
saveProjectSb3 () { saveProjectSb3 () {
// @todo: Handle other formats, e.g., Scratch 1.4, Scratch 2.0. const soundDescs = serializeSounds(this.runtime);
return this.toJSON(); 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, storage.DataFormat.SVG,
(new TextEncoder()).encode(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(); this.emitTargetsUpdate();
} }