Merge pull request #552 from rschamp/feature/serialization

Add project serialization
This commit is contained in:
Ray Schamp authored and GitHub committed 2017-05-11 13:07:47 -04:00
commit 312e15932e
13 files changed
+374 -47

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@
"scratch-audio": "^0.1.0-prerelease.0",
"scratch-blocks": "^0.1.0-prerelease.0",
"scratch-render": "^0.1.0-prerelease.0",
"scratch-storage": "^0.0.1-prerelease.0",
"scratch-storage": "^0.1.0",
"script-loader": "0.7.0",
"stats.js": "^0.17.0",
"tap": "^10.2.0",
+2 -1
View File
@@ -30,7 +30,8 @@ const loadCostume = function (md5ext, costume, runtime) {
];
let promise = runtime.storage.load(assetType, md5).then(costumeAsset => {
costume.url = costumeAsset.encodeDataURI();
costume.assetId = costumeAsset.assetId;
costume.assetType = assetType;
return costumeAsset;
});
+11 -4
View File
@@ -19,10 +19,17 @@ const loadSound = function (sound, runtime) {
}
const idParts = sound.md5.split('.');
const md5 = idParts[0];
return runtime.storage.load(runtime.storage.AssetType.Sound, md5).then(soundAsset => {
sound.data = soundAsset.data;
return runtime.audioEngine.decodeSound(sound).then(() => sound);
});
return runtime.storage.load(runtime.storage.AssetType.Sound, md5)
.then(soundAsset => {
sound.assetId = soundAsset.assetId;
sound.assetType = runtime.storage.AssetType.Sound;
return runtime.audioEngine.decodeSound(Object.assign(
{},
sound,
{data: soundAsset.data}
));
})
.then(() => sound);
};
module.exports = loadSound;
+7 -8
View File
@@ -53,9 +53,9 @@
<button id="projectLoadButton">Load</button>
<button id="createEmptyProject">New Project</button><br />
<p>
<input type="button" value="Export to XML" onclick="toXml()">
<input type="button" value="Export to JSON" onclick="toJson()">
&nbsp;
<input type="button" value="Import from XML" onclick="fromXml()" id="import">
<input type="button" value="Import from JSON" onclick="fromJson()" id="import">
<br /><br />
<textarea id="importExport"></textarea>
</p>
@@ -71,18 +71,17 @@
<!-- Playground -->
<script src="./playground.js"></script>
<script>
function toXml() {
function toJson() {
var output = document.getElementById('importExport');
var xml = Blockly.Xml.workspaceToDom(Scratch.workspace);
output.value = Blockly.Xml.domToPrettyText(xml);
var json = window.Scratch.vm.toJSON();
output.value = json;
output.focus();
output.select();
}
function fromXml() {
function fromJson() {
var input = document.getElementById('importExport');
var xml = Blockly.Xml.textToDom(input.value);
Blockly.Xml.domToWorkspace(Scratch.workspace, xml);
window.Scratch.vm.fromJSON(input.value);
}
</script>
</body>
@@ -11,12 +11,12 @@ const Sprite = require('../sprites/sprite');
const Color = require('../util/color');
const log = require('../util/log');
const uid = require('../util/uid');
const specMap = require('./sb2specmap');
const specMap = require('./sb2_specmap');
const Variable = require('../engine/variable');
const List = require('../engine/list');
const loadCostume = require('./load-costume.js');
const loadSound = require('./load-sound.js');
const loadCostume = require('../import/load-costume.js');
const loadSound = require('../import/load-sound.js');
/**
* Convert a Scratch 2.0 procedure string (e.g., "my_procedure %s %b %n")
@@ -276,14 +276,14 @@ const parseScratchObject = function (object, runtime, topLevel) {
/**
* Top-level handler. Parse provided JSON,
* and process the top-level object (the stage object).
* @param {!string} json SB2-format JSON to load.
* @param {!object} json SB2-format JSON to load.
* @param {!Runtime} runtime Runtime object to load all structures into.
* @param {boolean=} optForceSprite If set, treat as sprite (Sprite2).
* @return {?Promise} Promise that resolves to the loaded targets when ready.
*/
const sb2import = function (json, runtime, optForceSprite) {
return parseScratchObject(
JSON.parse(json),
json,
runtime,
!optForceSprite
);
@@ -470,4 +470,6 @@ const parseBlock = function (sb2block) {
return activeBlock;
};
module.exports = sb2import;
module.exports = {
deserialize: sb2import
};
File renamed without changes.
+165
View File
@@ -0,0 +1,165 @@
/**
* @fileoverview
* Partial implementation of a SB3 serializer and deserializer. Parses provided
* JSON and then generates all needed scratch-vm runtime structures.
*/
const vmPackage = require('../../package.json');
const Blocks = require('../engine/blocks');
const Sprite = require('../sprites/sprite');
const Variable = require('../engine/variable');
const List = require('../engine/list');
const loadCostume = require('../import/load-costume.js');
const loadSound = require('../import/load-sound.js');
/**
* Serializes the specified VM runtime.
* @param {!Runtime} runtime VM runtime instance to be serialized.
* @return {object} Serialized runtime instance.
*/
const serialize = function (runtime) {
// Fetch targets
const obj = Object.create(null);
obj.targets = runtime.targets;
// Assemble metadata
const meta = Object.create(null);
meta.semver = '3.0.0';
meta.vm = vmPackage.version;
// Attach full user agent string to metadata if available
meta.agent = null;
if (typeof navigator !== 'undefined') meta.agent = navigator.userAgent;
// Assemble payload and return
obj.meta = meta;
return obj;
};
/**
* Parse a single "Scratch object" and create all its in-memory VM objects.
* @param {!object} object From-JSON "Scratch object:" sprite, stage, watcher.
* @param {!Runtime} runtime Runtime object to load all structures into.
* @return {?Target} Target created (stage or sprite).
*/
const parseScratchObject = function (object, runtime) {
if (!object.hasOwnProperty('name')) {
// Watcher/monitor - skip this object until those are implemented in VM.
// @todo
return;
}
// Blocks container for this object.
const blocks = new Blocks();
// @todo: For now, load all Scratch objects (stage/sprites) as a Sprite.
const sprite = new Sprite(blocks, runtime);
// Sprite/stage name from JSON.
if (object.hasOwnProperty('name')) {
sprite.name = object.name;
}
if (object.hasOwnProperty('blocks')) {
for (let blockId in object.blocks) {
blocks.createBlock(object.blocks[blockId]);
}
// console.log(blocks);
}
// Costumes from JSON.
const costumePromises = (object.costumes || []).map(costumeSource => {
// @todo: Make sure all the relevant metadata is being pulled out.
const costume = {
skinId: null,
name: costumeSource.name,
bitmapResolution: costumeSource.bitmapResolution,
rotationCenterX: costumeSource.rotationCenterX,
rotationCenterY: costumeSource.rotationCenterY
};
const costumeMd5 = `${costumeSource.assetId}.${costumeSource.assetType.runtimeFormat}`;
return loadCostume(costumeMd5, costume, runtime);
});
// Sounds from JSON
const soundPromises = (object.sounds || []).map(soundSource => {
const sound = {
format: soundSource.format,
fileUrl: soundSource.fileUrl,
rate: soundSource.rate,
sampleCount: soundSource.sampleCount,
soundID: soundSource.soundID,
name: soundSource.name,
md5: soundSource.md5,
data: null
};
return loadSound(sound, runtime);
});
// Create the first clone, and load its run-state from JSON.
const target = sprite.createClone();
// Load target properties from JSON.
if (object.hasOwnProperty('variables')) {
for (let j = 0; j < object.variables.length; j++) {
const variable = object.variables[j];
target.variables[variable.name] = new Variable(
variable.name,
variable.value,
variable.isPersistent
);
}
}
if (object.hasOwnProperty('lists')) {
for (let k = 0; k < object.lists.length; k++) {
const list = object.lists[k];
// @todo: monitor properties.
target.lists[list.listName] = new List(
list.listName,
list.contents
);
}
}
if (object.hasOwnProperty('x')) {
target.x = object.x;
}
if (object.hasOwnProperty('y')) {
target.y = object.y;
}
if (object.hasOwnProperty('direction')) {
target.direction = object.direction;
}
if (object.hasOwnProperty('size')) {
target.size = object.size;
}
if (object.hasOwnProperty('visible')) {
target.visible = object.visible;
}
if (object.hasOwnProperty('currentCostume')) {
target.currentCostume = object.currentCostume;
}
if (object.hasOwnProperty('rotationStyle')) {
target.rotationStyle = object.rotationStyle;
}
if (object.hasOwnProperty('isStage')) {
target.isStage = object.isStage;
}
Promise.all(costumePromises).then(costumes => {
sprite.costumes = costumes;
});
Promise.all(soundPromises).then(sounds => {
sprite.sounds = sounds;
});
return Promise.all(costumePromises.concat(soundPromises)).then(() => target);
};
/**
* Deserializes the specified representation of a VM runtime and loads it into
* the provided runtime instance.
* @param {object} json JSON representation of a VM runtime.
* @param {Runtime} runtime Runtime instance
* @returns {Promise} Promise that resolves to the list of targets after the project is deserialized
*/
const deserialize = function (json, runtime) {
return Promise.all((json.targets || []).map(target => parseScratchObject(target, runtime)));
};
module.exports = {
serialize: serialize,
deserialize: deserialize
};
+12 -6
View File
@@ -764,11 +764,13 @@ class RenderedTarget extends Target {
this.dragging = false;
}
/**
* Serialize sprite info, used when emitting events about the sprite
* @returns {object} sprite data as a simple object
* @returns {object} Sprite data as a simple object
*/
toJSON () {
const costumes = this.getCostumes();
return {
id: this.id,
name: this.getName(),
@@ -778,12 +780,16 @@ class RenderedTarget extends Target {
size: this.size,
direction: this.direction,
draggable: this.draggable,
costume: this.getCurrentCostume(),
costumes: this.getCostumes(),
sounds: this.getSounds(),
costumeCount: this.getCostumes().length,
currentCostume: this.currentCostume,
costume: costumes[this.currentCostume],
costumeCount: costumes.length,
visible: this.visible,
rotationStyle: this.rotationStyle
rotationStyle: this.rotationStyle,
blocks: this.blocks._blocks,
variables: this.variables,
lists: this.lists,
costumes: costumes,
sounds: this.getSounds()
};
}
+87 -18
View File
@@ -2,7 +2,8 @@ const EventEmitter = require('events');
const log = require('./util/log');
const Runtime = require('./engine/runtime');
const sb2import = require('./import/sb2import');
const sb2 = require('./serialization/sb2');
const sb3 = require('./serialization/sb3');
const StringUtil = require('./util/string-util');
const loadCostume = require('./import/load-costume.js');
@@ -145,22 +146,7 @@ class VirtualMachine extends EventEmitter {
*/
loadProject (json) {
// @todo: Handle other formats, e.g., Scratch 1.4, Scratch 3.0.
return sb2import(json, this.runtime).then(targets => {
this.clear();
for (let n = 0; n < targets.length; n++) {
if (targets[n] !== null) {
this.runtime.targets.push(targets[n]);
targets[n].updateAllDrawableProperties();
}
}
// Select the first target for editing, e.g., the first sprite.
this.editingTarget = this.runtime.targets[1];
// Update the VM user's knowledge of targets and blocks on the workspace.
this.emitTargetsUpdate();
this.emitWorkspaceUpdate();
this.runtime.setEditingTarget(this.editingTarget);
});
return this.fromJSON(json);
}
/**
@@ -180,12 +166,95 @@ 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();
}
/**
* Export project as a Scratch 3.0 JSON representation.
* @return {string} Serialized state of the runtime.
*/
toJSON () {
return JSON.stringify(sb3.serialize(this.runtime));
}
/**
* Load a project from a Scratch JSON representation.
* @param {string} json JSON string representing a project.
* @returns {Promise} Promise that resolves after the project has loaded
*/
fromJSON (json) {
// Clear the current runtime
this.clear();
// Validate & parse
if (typeof json !== 'string') {
log.error('Failed to parse project. Non-string supplied to fromJSON.');
return;
}
json = JSON.parse(json);
if (typeof json !== 'object') {
log.error('Failed to parse project. JSON supplied to fromJSON is not an object.');
return;
}
// Establish version, deserialize, and load into runtime
// @todo Support Scratch 1.4
// @todo This is an extremely naïve / dangerous way of determining version.
// See `scratch-parser` for a more sophisticated validation
// methodology that should be adapted for use here
let deserializer;
if ((typeof json.meta !== 'undefined') && (typeof json.meta.semver !== 'undefined')) {
deserializer = sb3;
} else {
deserializer = sb2;
}
return deserializer.deserialize(json, this.runtime).then(targets => {
this.clear();
for (let n = 0; n < targets.length; n++) {
if (targets[n] !== null) {
this.runtime.targets.push(targets[n]);
targets[n].updateAllDrawableProperties();
}
}
// Select the first target for editing, e.g., the first sprite.
if (this.runtime.targets.length > 1) {
this.editingTarget = this.runtime.targets[1];
} else {
this.editingTarget = this.runtime.targets[0];
}
// Update the VM user's knowledge of targets and blocks on the workspace.
this.emitTargetsUpdate();
this.emitWorkspaceUpdate();
this.runtime.setEditingTarget(this.editingTarget);
});
}
/**
* Add a single sprite from the "Sprite2" (i.e., SB2 sprite) format.
* @param {string} json JSON string representing the sprite.
* @returns {Promise} Promise that resolves after the sprite is added
*/
addSprite2 (json) {
sb2import(json, this.runtime, true).then(targets => {
// Validate & parse
if (typeof json !== 'string') {
log.error('Failed to parse sprite. Non-string supplied to addSprite2.');
return;
}
json = JSON.parse(json);
if (typeof json !== 'object') {
log.error('Failed to parse sprite. JSON supplied to addSprite2 is not an object.');
return;
}
// Select new sprite.
return sb2.deserialize(json, this.runtime, true).then(targets => {
this.runtime.targets.push(targets[0]);
this.editingTarget = targets[0];
this.editingTarget.updateAllDrawableProperties();
+1
View File
@@ -0,0 +1 @@
{"targets":[{"id":"poXv!.~0@*~j*F(Om/X5","name":"Stage","isStage":true,"x":0,"y":0,"size":100,"direction":90,"draggable":false,"currentCostume":10,"costume":{"name":"backdrop2","bitmapResolution":1,"rotationCenterX":240,"rotationCenterY":180,"skinId":7,"assetId":"7da4181ee167de7b3f5d1a91880277ff","assetType":{"contentType":"image/svg+xml","name":"ImageVector","runtimeFormat":"svg"}},"costumeCount":11,"visible":true,"rotationStyle":"all around","blocks":{"O7D.%E~TH^ULpAuHM8)@":{"id":"O7D.%E~TH^ULpAuHM8)@","opcode":"event_whenflagclicked","inputs":{},"fields":{},"next":"78+=E[P7b8!mBXMwQF`@","shadow":false,"x":138,"y":356.40000000000003,"topLevel":true,"parent":null},"78+=E[P7b8!mBXMwQF`@":{"id":"78+=E[P7b8!mBXMwQF`@","opcode":"control_forever","inputs":{"SUBSTACK":{"name":"SUBSTACK","block":"nlnk{2XqtVI*Qg,admbW","shadow":null}},"fields":{},"next":null,"shadow":false,"parent":"O7D.%E~TH^ULpAuHM8)@"},"nlnk{2XqtVI*Qg,admbW":{"id":"nlnk{2XqtVI*Qg,admbW","opcode":"looks_changeeffectby","inputs":{"EFFECT":{"name":"EFFECT","block":"*vh;qV87Q}5IP@sW=)wD","shadow":"*vh;qV87Q}5IP@sW=)wD"},"CHANGE":{"name":"CHANGE","block":"woYo[[v=PD(`R;qW{PZ%","shadow":"woYo[[v=PD(`R;qW{PZ%"}},"fields":{},"next":null,"shadow":false,"parent":"78+=E[P7b8!mBXMwQF`@"},"*vh;qV87Q}5IP@sW=)wD":{"id":"*vh;qV87Q}5IP@sW=)wD","opcode":"looks_effectmenu","inputs":{},"fields":{"EFFECT":{"name":"EFFECT","value":"color"}},"next":null,"topLevel":false,"parent":"nlnk{2XqtVI*Qg,admbW","shadow":true},"woYo[[v=PD(`R;qW{PZ%":{"id":"woYo[[v=PD(`R;qW{PZ%","opcode":"math_number","inputs":{},"fields":{"NUM":{"name":"NUM","value":1}},"next":null,"topLevel":false,"parent":"nlnk{2XqtVI*Qg,admbW","shadow":true}},"variables":{"vy":{"name":"vy","value":-3.5165000000000006,"isCloud":false},"g":{"name":"g","value":"-.5","isCloud":false},"vx":{"name":"vx","value":1.4580000000000002,"isCloud":false},"i":{"name":"i","value":3,"isCloud":false},"d1":{"name":"d1","value":0,"isCloud":false},"x":{"name":"x","value":233.9173278872492,"isCloud":false},"acceleration":{"name":"acceleration","value":"-3","isCloud":false}},"lists":{},"costumes":[{"name":"blue sky2","bitmapResolution":1,"rotationCenterX":240,"rotationCenterY":180,"skinId":0,"assetId":"7623e679b2baa2e7d48808614820844f","assetType":{"contentType":"image/svg+xml","name":"ImageVector","runtimeFormat":"svg"}},{"name":"woods","bitmapResolution":1,"rotationCenterX":240,"rotationCenterY":180,"skinId":3,"assetId":"1e0f7a4c932423b13250b5cb44928109","assetType":{"contentType":"image/svg+xml","name":"ImageVector","runtimeFormat":"svg"}},{"name":"party","bitmapResolution":1,"rotationCenterX":251,"rotationCenterY":189,"skinId":2,"assetId":"108160d0e44d1c340182e31c9dc0758a","assetType":{"contentType":"image/svg+xml","name":"ImageVector","runtimeFormat":"svg"}},{"name":"boardwalk","bitmapResolution":2,"rotationCenterX":480,"rotationCenterY":360,"skinId":6,"assetId":"de0e54cd11551566f044e7e6bc588b2c","assetType":{"contentType":"image/png","name":"ImageBitmap","runtimeFormat":"png"}},{"name":"blue sky3","bitmapResolution":1,"rotationCenterX":240,"rotationCenterY":179,"skinId":1,"assetId":"2024d59c1980e667e8f656134796e2c1","assetType":{"contentType":"image/svg+xml","name":"ImageVector","runtimeFormat":"svg"}},{"name":"underwater1","bitmapResolution":2,"rotationCenterX":480,"rotationCenterY":360,"skinId":5,"assetId":"f339c6f31b11ea71d0fb8d607cec392e","assetType":{"contentType":"image/png","name":"ImageBitmap","runtimeFormat":"png"}},{"name":"underwater2","bitmapResolution":2,"rotationCenterX":480,"rotationCenterY":360,"skinId":11,"assetId":"1517c21786d2d0edc2f3037408d850bd","assetType":{"contentType":"image/png","name":"ImageBitmap","runtimeFormat":"png"}},{"name":"stars","bitmapResolution":2,"rotationCenterX":480,"rotationCenterY":360,"skinId":4,"assetId":"e87fed9c2a968dbeae8c94617e600e8c","assetType":{"contentType":"image/png","name":"ImageBitmap","runtimeFormat":"png"}},{"name":"parking-ramp","bitmapResolution":2,"rotationCenterX":480,"rotationCenterY":360,"skinId":9,"assetId":"a7832479977c166ca0057f2a99a73305","assetType":{"contentType":"image/png","name":"ImageBitmap","runtimeFormat":"png"}},{"name":"backdrop1","bitmapResolution":2,"rotationCenterX":480,"rotationCenterY":360,"skinId":10,"assetId":"f67dc7de38bac6fbb0ab68e46352521d","assetType":{"contentType":"image/png","name":"ImageBitmap","runtimeFormat":"png"}},{"name":"backdrop2","bitmapResolution":1,"rotationCenterX":240,"rotationCenterY":180,"skinId":7,"assetId":"7da4181ee167de7b3f5d1a91880277ff","assetType":{"contentType":"image/svg+xml","name":"ImageVector","runtimeFormat":"svg"}}],"sounds":[{"name":"pop","format":"","rate":11025,"sampleCount":258,"soundID":-1,"md5":"83a9787d4cb6f3b7632b4ddfebf74367.wav","data":null,"assetId":"83a9787d4cb6f3b7632b4ddfebf74367","assetType":{"contentType":"audio/x-wav","name":"Sound","runtimeFormat":"wav"}}]},{"id":"9!d8G^[6i]k*:l[W%4;l","name":"Earth","isStage":false,"x":-10,"y":10,"size":100,"direction":90,"draggable":false,"currentCostLine truncated
+5 -3
View File
@@ -5,10 +5,10 @@ const extract = require('../fixtures/extract');
const renderedTarget = require('../../src/sprites/rendered-target');
const runtime = require('../../src/engine/runtime');
const sb2 = require('../../src/import/sb2import');
const sb2 = require('../../src/serialization/sb2');
test('spec', t => {
t.type(sb2, 'function');
t.type(sb2.deserialize, 'function');
t.end();
});
@@ -16,13 +16,15 @@ test('default', t => {
// Get SB2 JSON (string)
const uri = path.resolve(__dirname, '../fixtures/default.sb2');
const file = extract(uri);
const json = JSON.parse(file);
// Create runtime instance & load SB2 into it
const rt = new runtime();
attachTestStorage(rt);
sb2(file, rt).then(targets => {
sb2.deserialize(json, rt).then(targets => {
// Test
t.type(file, 'string');
t.type(json, 'object');
t.type(rt, 'object');
t.type(targets, 'object');
+53
View File
@@ -0,0 +1,53 @@
const path = require('path');
const test = require('tap').test;
const extract = require('../fixtures/extract');
const RenderedTarget = require('../../src/sprites/rendered-target');
const Runtime = require('../../src/engine/runtime');
const sb2 = require('../../src/serialization/sb2');
test('spec', t => {
t.type(sb2, 'object');
t.type(sb2.deserialize, 'function');
t.end();
});
test('default', t => {
// Get SB2 JSON (string)
const uri = path.resolve(__dirname, '../fixtures/default.sb2');
const file = extract(uri);
const json = JSON.parse(file);
// Create runtime instance & load SB2 into it
const rt = new Runtime();
sb2.deserialize(json, rt).then(targets => {
// Test
t.type(file, 'string');
t.type(json, 'object');
t.type(rt, 'object');
t.type(targets, 'object');
t.ok(targets[0] instanceof RenderedTarget);
t.type(targets[0].id, 'string');
t.type(targets[0].blocks, 'object');
t.type(targets[0].variables, 'object');
t.type(targets[0].lists, 'object');
t.equal(targets[0].isOriginal, true);
t.equal(targets[0].currentCostume, 0);
t.equal(targets[0].isOriginal, true);
t.equal(targets[0].isStage, true);
t.ok(targets[1] instanceof RenderedTarget);
t.type(targets[1].id, 'string');
t.type(targets[1].blocks, 'object');
t.type(targets[1].variables, 'object');
t.type(targets[1].lists, 'object');
t.equal(targets[1].isOriginal, true);
t.equal(targets[1].currentCostume, 0);
t.equal(targets[1].isOriginal, true);
t.equal(targets[1].isStage, false);
t.end();
});
});
+22
View File
@@ -0,0 +1,22 @@
const test = require('tap').test;
const VirtualMachine = require('../../src/index');
const sb3 = require('../../src/serialization/sb3');
const demoSb3 = require('../fixtures/demo.json');
test('serialize', t => {
const vm = new VirtualMachine();
vm.fromJSON(JSON.stringify(demoSb3));
const result = sb3.serialize(vm.runtime);
// @todo Analyze
t.type(JSON.stringify(result), 'string');
t.end();
});
test('deserialize', t => {
const vm = new VirtualMachine();
sb3.deserialize('', vm.runtime).then(targets => {
// @todo Analyize
t.type(targets, 'object');
t.end();
});
});