mirror of
https://github.com/scratchfoundation/scratch-vm.git
synced 2024-12-24 15:02:52 -05:00
Merge pull request #964 from kchadha/save-load
Preliminary Save and Load Work
This commit is contained in:
commit
adfbeddcab
5 changed files with 253 additions and 8 deletions
|
@ -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",
|
||||||
|
|
134
src/serialization/deserialize-assets.js
Normal file
134
src/serialization/deserialize-assets.js
Normal 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
|
||||||
|
};
|
|
@ -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
|
||||||
|
|
55
src/serialization/serialize-assets.js
Normal file
55
src/serialization/serialize-assets.js
Normal 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
|
||||||
|
};
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue