From 420aa4fba85874eccb00dca7b0713358ee6d7c82 Mon Sep 17 00:00:00 2001 From: Thomas Weber Date: Thu, 2 Jan 2025 01:19:06 -0600 Subject: [PATCH] lazy: export assets from unloaded lazy sprites --- src/sprites/tw-lazy-sprite.js | 35 ++++++++++++++++++++++++ src/virtual-machine.js | 51 +++++++++++++++++++++++------------ test/integration/tw_lazy.js | 6 ++++- 3 files changed, 74 insertions(+), 18 deletions(-) diff --git a/src/sprites/tw-lazy-sprite.js b/src/sprites/tw-lazy-sprite.js index b27848de521..8c358a68473 100644 --- a/src/sprites/tw-lazy-sprite.js +++ b/src/sprites/tw-lazy-sprite.js @@ -237,6 +237,41 @@ class LazySprite extends Sprite { this.runtime.disposeTarget(target); } } + + /** + * Fetch all assets used in this sprite for serialization. + * @returns {Promise>} + */ + async serializeAssets () { + // Loaded lazily to avoid circular dependencies + const deserializeAssets = require('../serialization/deserialize-assets'); + + const promises = []; + for (const costume of this.object.costumes) { + if (!costume.asset) { + promises.push(deserializeAssets.deserializeCostume(costume, this.runtime, assetCacheSingleton)); + } + } + for (const sound of this.object.sounds) { + if (!sound.asset) { + promises.push(deserializeAssets.deserializeSound(sound, this.runtime, assetCacheSingleton)); + } + } + await Promise.all(promises); + + const allResources = [ + ...this.object.costumes, + ...this.object.sounds + ]; + + return allResources + .map(o => (o.broken ? o.broken.asset : o.asset)) + .filter(asset => asset) + .map(asset => ({ + fileName: `${asset.assetId}.${asset.dataFormat}`, + fileContent: asset.data + })); + } } // Export enums diff --git a/src/virtual-machine.js b/src/virtual-machine.js index a12fc23ba4c..8cb6f90ab38 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -514,9 +514,9 @@ class VirtualMachine extends EventEmitter { } /** - * @returns {JSZip} JSZip zip object representing the sb3. + * @returns {Promise} JSZip zip object representing the sb3. */ - _saveProjectZip () { + async _saveProjectZip () { const projectJson = this.toJSON(); // TODO want to eventually move zip creation out of here, and perhaps @@ -525,7 +525,7 @@ class VirtualMachine extends EventEmitter { // Put everything in a zip file zip.file('project.json', projectJson); - this._addFileDescsToZip(this.serializeAssets(), zip); + this._addFileDescsToZip(await this.serializeAssets(), zip); // Use a fixed modification date for the files in the zip instead of letting JSZip use the // current time to avoid a very small metadata leak and make zipping deterministic. The magic @@ -543,8 +543,9 @@ class VirtualMachine extends EventEmitter { * @param {JSZip.OutputType} [type] JSZip output type. Defaults to 'blob' for Scratch compatibility. * @returns {Promise} Compressed sb3 file in a type determined by the type argument. */ - saveProjectSb3 (type) { - return this._saveProjectZip().generateAsync({ + async saveProjectSb3 (type) { + const zip = await this._saveProjectZip(); + return zip.generateAsync({ type: type || 'blob', mimeType: 'application/x.scratch.sb3', compression: 'DEFLATE' @@ -553,11 +554,12 @@ class VirtualMachine extends EventEmitter { /** * @param {JSZip.OutputType} [type] JSZip output type. Defaults to 'arraybuffer'. - * @returns {StreamHelper} JSZip StreamHelper object generating the compressed sb3. + * @returns {Promise} JSZip StreamHelper object generating the compressed sb3. * See: https://stuk.github.io/jszip/documentation/api_streamhelper.html */ - saveProjectSb3Stream (type) { - return this._saveProjectZip().generateInternalStream({ + async saveProjectSb3Stream (type) { + const zip = await this._saveProjectZip(); + return zip.generateInternalStream({ type: type || 'arraybuffer', mimeType: 'application/x.scratch.sb3', compression: 'DEFLATE' @@ -601,19 +603,34 @@ class VirtualMachine extends EventEmitter { /** * @param {string} targetId Optional ID of target to export - * @returns {Array<{fileName: string; fileContent: Uint8Array;}} list of file descs + * @returns {Promise} list of file descs */ - serializeAssets (targetId) { - const costumeDescs = serializeCostumes(this.runtime, targetId); - const soundDescs = serializeSounds(this.runtime, targetId); + async serializeAssets (targetId) { + // This will include non-lazy sprites and loaded lazy sprites. + const loadedCostumeDescs = serializeCostumes(this.runtime, targetId); + const loadedSoundDescs = serializeSounds(this.runtime, targetId); + + // Assume every target needs all fonts. const fontDescs = this.runtime.fontManager.serializeAssets().map(asset => ({ fileName: `${asset.assetId}.${asset.dataFormat}`, fileContent: asset.data })); + + // Fetch assets used by lazy sprites. + const unloadedSprites = this.runtime.lazySprites.filter(i => i.clones.length === 0); + const unloadedSpriteDescs = await Promise.all(unloadedSprites.map(s => s.serializeAssets())); + const flattenedUnloadedSpriteDescs = []; + for (const descs of unloadedSpriteDescs) { + for (const desc of descs) { + flattenedUnloadedSpriteDescs.push(desc); + } + } + return [ - ...costumeDescs, - ...soundDescs, - ...fontDescs + ...loadedCostumeDescs, + ...loadedSoundDescs, + ...fontDescs, + ...flattenedUnloadedSpriteDescs ]; } @@ -637,12 +654,12 @@ class VirtualMachine extends EventEmitter { * @return {object} A generated zip of the sprite and its assets in the format * specified by optZipType or blob by default. */ - exportSprite (targetId, optZipType) { + async exportSprite (targetId, optZipType) { const spriteJson = this.toJSON(targetId); const zip = new JSZip(); zip.file('sprite.json', spriteJson); - this._addFileDescsToZip(this.serializeAssets(targetId), zip); + this._addFileDescsToZip(await this.serializeAssets(targetId), zip); return zip.generateAsync({ type: typeof optZipType === 'string' ? optZipType : 'blob', diff --git a/test/integration/tw_lazy.js b/test/integration/tw_lazy.js index bb9249cd1f9..7bae866588a 100644 --- a/test/integration/tw_lazy.js +++ b/test/integration/tw_lazy.js @@ -189,6 +189,7 @@ test('sb2 has no lazy sprites', t => { for (const load of [true, false]) { test(`export lazy sprites ${load ? 'after' : 'before'} loading`, t => { const vm = new VM(); + vm.attachStorage(makeTestStorage()); const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-simple.sb3')); vm.loadProject(fixture).then(async () => { @@ -216,7 +217,10 @@ for (const load of [true, false]) { delete fixtureJSON.targets[1].layerOrder; t.same(json.targets[1], fixtureJSON.targets[1]); - + + // Check for lazy loaded sprite's costume existing + t.not(zip.file('927d672925e7b99f7813735c484c6922.svg'), null); + t.end(); }); });