Skip to content

Commit e0b23cd

Browse files
committed
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.
1 parent 089df0a commit e0b23cd

4 files changed

Lines changed: 158 additions & 6 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"immutable": "3.8.1",
5050
"in-publish": "^2.0.0",
5151
"json": "^9.0.4",
52+
"jszip": "^3.1.5",
5253
"lodash.defaultsdeep": "4.6.0",
5354
"minilog": "3.1.0",
5455
"nets": "3.2.0",
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
const JSZip = require('jszip');
2+
const log = require('../util/log');
3+
4+
/**
5+
* Deserializes sound from file into storage cache so that it can
6+
* be loaded into the runtime.
7+
* @param {object} sound Descriptor for sound from sb3 file
8+
* @param {Runtime} runtime The runtime containing the storage to cache the sounds in
9+
* @param {JSZip} zip The zip containing the sound file being described by `sound`
10+
* @return {Promise} Promise that resolves after the described sound has been stored
11+
* into the runtime storage cache, the sound was already stored, or an error has
12+
* occurred.
13+
*/
14+
const deserializeSound = function (sound, runtime, zip) {
15+
const fileName = sound.md5; // The md5 property has the full file name
16+
const storage = runtime.storage;
17+
if (!storage) {
18+
log.error('No storage module present; cannot load sound asset: ', fileName);
19+
return Promise.resolve(null);
20+
}
21+
22+
const assetId = sound.assetId;
23+
24+
// TODO Is there a faster way to check that this asset
25+
// has already been initialized?
26+
if (storage.get(assetId)) {
27+
// This sound has already been cached.
28+
return Promise.resolve(null);
29+
}
30+
31+
const soundFile = zip.file(fileName);
32+
if (!soundFile) {
33+
log.error(`Could not find sound file associated with the ${sound.name} sound.`);
34+
return Promise.resolve(null);
35+
}
36+
let dataFormat = null;
37+
if (sound.dataFormat.toLowerCase() === 'wav') {
38+
dataFormat = storage.DataFormat.WAV;
39+
}
40+
if (!JSZip.support.uint8array) {
41+
log.error('JSZip uint8array is not supported in this browser.');
42+
return Promise.resolve(null);
43+
}
44+
45+
return soundFile.async('uint8array').then(data => {
46+
storage.builtinHelper.cache(
47+
storage.AssetType.Sound,
48+
dataFormat,
49+
data,
50+
assetId
51+
);
52+
});
53+
};
54+
55+
/**
56+
* Deserializes costume from file into storage cache so that it can
57+
* be loaded into the runtime.
58+
* @param {object} costume Descriptor for costume from sb3 file
59+
* @param {Runtime} runtime The runtime containing the storage to cache the costumes in
60+
* @param {JSZip} zip The zip containing the costume file being described by `costume`
61+
* @return {Promise} Promise that resolves after the described costume has been stored
62+
* into the runtime storage cache, the costume was already stored, or an error has
63+
* occurred.
64+
*/
65+
const deserializeCostume = function (costume, runtime, zip) {
66+
const storage = runtime.storage;
67+
const assetId = costume.assetId;
68+
const fileName = costume.md5 ?
69+
costume.md5 :
70+
`${assetId}.${costume.dataFormat}`; // The md5 property has the full file name
71+
72+
if (!storage) {
73+
log.error('No storage module present; cannot load costume asset: ', fileName);
74+
return Promise.resolve(null);
75+
}
76+
77+
78+
// TODO Is there a faster way to check that this asset
79+
// has already been initialized?
80+
if (storage.get(assetId)) {
81+
// This costume has already been cached.
82+
return Promise.resolve(null);
83+
}
84+
85+
const costumeFile = zip.file(fileName);
86+
if (!costumeFile) {
87+
log.error(`Could not find costume file associated with the ${costume.name} costume.`);
88+
return Promise.resolve(null);
89+
}
90+
let dataFormat = null;
91+
let assetType = null;
92+
const costumeFormat = costume.dataFormat.toLowerCase();
93+
if (costumeFormat === 'svg') {
94+
dataFormat = storage.DataFormat.SVG;
95+
assetType = storage.AssetType.ImageVector;
96+
} else if (costumeFormat === 'png') {
97+
dataFormat = storage.DataFormat.PNG;
98+
assetType = storage.AssetType.ImageBitmap;
99+
} else {
100+
log.error(`Unexpected file format for costume: ${costumeFormat}`);
101+
}
102+
if (!JSZip.support.uint8array) {
103+
log.error('JSZip uint8array is not supported in this browser.');
104+
return Promise.resolve(null);
105+
}
106+
107+
return costumeFile.async('uint8array').then(data => {
108+
storage.builtinHelper.cache(
109+
assetType,
110+
dataFormat,
111+
data,
112+
assetId
113+
);
114+
});
115+
};
116+
117+
module.exports = {
118+
deserializeSound,
119+
deserializeCostume
120+
};

src/serialization/sb3.js

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const Variable = require('../engine/variable');
1111

1212
const {loadCostume} = require('../import/load-costume.js');
1313
const {loadSound} = require('../import/load-sound.js');
14+
const {deserializeCostume, deserializeSound} = require('./deserialize-assets.js');
1415

1516
/**
1617
* @typedef {object} ImportedProject
@@ -53,9 +54,10 @@ const serialize = function (runtime) {
5354
* @param {!object} object From-JSON "Scratch object:" sprite, stage, watcher.
5455
* @param {!Runtime} runtime Runtime object to load all structures into.
5556
* @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here.
57+
* @param {JSZip} zip Sb3 file describing this project (to load assets from)
5658
* @return {!Promise.<Target>} Promise for the target created (stage or sprite), or null for unsupported objects.
5759
*/
58-
const parseScratchObject = function (object, runtime, extensions) {
60+
const parseScratchObject = function (object, runtime, extensions, zip) {
5961
if (!object.hasOwnProperty('name')) {
6062
// Watcher/monitor - skip this object until those are implemented in VM.
6163
// @todo
@@ -99,21 +101,28 @@ const parseScratchObject = function (object, runtime, extensions) {
99101
(costumeSource.assetType && costumeSource.assetType.runtimeFormat) || // older format
100102
'png'; // if all else fails, guess that it might be a PNG
101103
const costumeMd5 = `${costumeSource.assetId}.${dataFormat}`;
102-
return loadCostume(costumeMd5, costume, runtime);
104+
costume.md5 = costumeMd5;
105+
return deserializeCostume(costumeSource, runtime, zip)
106+
.then(() => loadCostume(costumeMd5, costume, runtime));
107+
// Only attempt to load the costume after the deserialization
108+
// process has been completed
103109
});
104110
// Sounds from JSON
105111
const soundPromises = (object.sounds || []).map(soundSource => {
106112
const sound = {
107113
format: soundSource.format,
108-
fileUrl: soundSource.fileUrl,
114+
// fileUrl: soundSource.fileUrl,
109115
rate: soundSource.rate,
110116
sampleCount: soundSource.sampleCount,
111117
soundID: soundSource.soundID,
112118
name: soundSource.name,
113119
md5: soundSource.md5,
114120
data: null
115121
};
116-
return loadSound(sound, runtime);
122+
return deserializeSound(soundSource, runtime, zip)
123+
.then(() => loadSound(sound, runtime));
124+
// Only attempt to load the sound after the deserialization
125+
// process has been completed.
117126
});
118127
// Create the first clone, and load its run-state from JSON.
119128
const target = sprite.createClone();
@@ -169,15 +178,16 @@ const parseScratchObject = function (object, runtime, extensions) {
169178
* TODO: parse extension info (also, design extension info storage...)
170179
* @param {object} json - JSON representation of a VM runtime.
171180
* @param {Runtime} runtime - Runtime instance
181+
* @param {JSZip} zip - Sb3 file describing this project (to load assets from)
172182
* @returns {Promise.<ImportedProject>} Promise that resolves to the list of targets after the project is deserialized
173183
*/
174-
const deserialize = function (json, runtime) {
184+
const deserialize = function (json, runtime, zip) {
175185
const extensions = {
176186
extensionIDs: new Set(),
177187
extensionURLs: new Map()
178188
};
179189
return Promise.all(
180-
(json.targets || []).map(target => parseScratchObject(target, runtime, extensions))
190+
(json.targets || []).map(target => parseScratchObject(target, runtime, extensions, zip))
181191
).then(targets => ({
182192
targets,
183193
extensions

src/virtual-machine.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,27 @@ class VirtualMachine extends EventEmitter {
185185
return this.fromJSON(json);
186186
}
187187

188+
/**
189+
* Load a project from a Scratch 3.0 sb3 file containing a project json
190+
* and all of the sound and costume files.
191+
* @param {JSZip} sb3File The sb3 file representing the project to load.
192+
* @return {!Promise} Promise that resolves after targets are installed.
193+
*/
194+
loadProjectLocal (sb3File) {
195+
// TODO need to handle sb2 files as well, and will possibly merge w/
196+
// above function
197+
return sb3File.file('project.json').async('string')
198+
.then(json => {
199+
// TODO look at promise documentation to do this on success,
200+
// but something else on error
201+
202+
json = JSON.parse(json); // TODO catch errors here (validation)
203+
return sb3.deserialize(json, this.runtime, sb3File)
204+
.then(({targets, extensions}) =>
205+
this.installTargets(targets, extensions, true));
206+
});
207+
}
208+
188209
/**
189210
* Load a project from the Scratch web site, by ID.
190211
* @param {string} id - the ID of the project to download, as a string.

0 commit comments

Comments
 (0)