From 1ae5a1f7e5ea5059c4a441524ae2a00161ed3f73 Mon Sep 17 00:00:00 2001 From: Thomas Weber Date: Wed, 18 Dec 2024 22:08:37 -0600 Subject: [PATCH 1/9] lazy: Return full error in addSprite Technically a breaking change. --- src/virtual-machine.js | 4 +--- test/unit/virtual-machine.js | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 63872094c2c..052f3288174 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -851,9 +851,7 @@ class VirtualMachine extends EventEmitter { if (Object.prototype.hasOwnProperty.call(error, 'validationError')) { return Promise.reject(JSON.stringify(error)); } - // TODO: reject with an Error (possible breaking API change!) - // eslint-disable-next-line prefer-promise-reject-errors - return Promise.reject(`${errorPrefix} ${error}`); + return Promise.reject(error); }); } diff --git a/test/unit/virtual-machine.js b/test/unit/virtual-machine.js index 36284708856..cf0c7336a8d 100644 --- a/test/unit/virtual-machine.js +++ b/test/unit/virtual-machine.js @@ -62,7 +62,7 @@ test('addSprite throws on invalid string', t => { const vm = new VirtualMachine(); vm.addSprite('this is not a sprite') .catch(e => { - t.equal(e.startsWith('Sprite Upload Error:'), true); + t.equal(e.startsWith('SyntaxError:'), true); t.end(); }); }); From 9ac98c0f51a6c8c2681cc27dccfc25cee66af7b0 Mon Sep 17 00:00:00 2001 From: Thomas Weber Date: Wed, 18 Dec 2024 22:12:59 -0600 Subject: [PATCH 2/9] lazy: Convert sb3 deserialize to a proper async function --- src/serialization/sb3.js | 79 +++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index 815c2837a4d..d6589ba8748 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -1535,11 +1535,8 @@ const deserialize = async function (json, runtime, zip, isSingleSprite) { } // Extract any custom fonts before loading costumes. - let fontPromise; if (json.customFonts) { - fontPromise = runtime.fontManager.deserialize(json.customFonts, zip, isSingleSprite); - } else { - fontPromise = Promise.resolve(); + await runtime.fontManager.deserialize(json.customFonts, zip, isSingleSprite); } // First keep track of the current target order in the json, @@ -1550,42 +1547,48 @@ const deserialize = async function (json, runtime, zip, isSingleSprite) { .map((t, i) => Object.assign(t, {targetPaneOrder: i})) .sort((a, b) => a.layerOrder - b.layerOrder); + const eagerTargets = targetObjects.filter(i => i.lazy !== true); + const assets = eagerTargets.map(target => parseScratchAssets(target, runtime, zip)); + + // Force to wait for the next loop in the js tick. Let + // storage have some time to send off asset requests. + await Promise.resolve(); + + const unsortedTargets = await Promise.all( + eagerTargets.map((target, index) => parseScratchObject(target, runtime, extensions, zip, assets[index])) + ); + + // Re-sort targets back into original sprite-pane ordering + const sortedTargets = unsortedTargets + .map((t, i) => { + // Add layer order property to deserialized targets. + // This property is used to initialize executable targets in + // the correct order and is deleted in VM's installTargets function + t.layerOrder = i; + return t; + }) + .sort((a, b) => a.targetPaneOrder - b.targetPaneOrder) + .map(t => { + // Delete the temporary properties used for + // sprite pane ordering and stage layer ordering + delete t.targetPaneOrder; + return t; + }); + + const targets = replaceUnsafeCharsInVariableIds(sortedTargets); + const monitorObjects = json.monitors || []; + monitorObjects.map(monitorDesc => deserializeMonitor(monitorDesc, runtime, targets, extensions)); - return fontPromise.then(() => targetObjects.map(target => parseScratchAssets(target, runtime, zip))) - // Force this promise to wait for the next loop in the js tick. Let - // storage have some time to send off asset requests. - .then(assets => Promise.resolve(assets)) - .then(assets => Promise.all(targetObjects - .map((target, index) => - parseScratchObject(target, runtime, extensions, zip, assets[index])))) - .then(targets => targets // Re-sort targets back into original sprite-pane ordering - .map((t, i) => { - // Add layer order property to deserialized targets. - // This property is used to initialize executable targets in - // the correct order and is deleted in VM's installTargets function - t.layerOrder = i; - return t; - }) - .sort((a, b) => a.targetPaneOrder - b.targetPaneOrder) - .map(t => { - // Delete the temporary properties used for - // sprite pane ordering and stage layer ordering - delete t.targetPaneOrder; - return t; - })) - .then(targets => replaceUnsafeCharsInVariableIds(targets)) - .then(targets => { - monitorObjects.map(monitorDesc => deserializeMonitor(monitorDesc, runtime, targets, extensions)); - if (Object.prototype.hasOwnProperty.call(json, 'extensionStorage')) { - runtime.extensionStorage = json.extensionStorage; - } - return targets; - }) - .then(targets => ({ - targets, - extensions - })); + if (Object.prototype.hasOwnProperty.call(json, 'extensionStorage')) { + // eslint-disable-next-line require-atomic-updates + runtime.extensionStorage = json.extensionStorage; + } + + return { + targets, + extensions + }; }; module.exports = { From 53eef7f1c4f6e1c4a96ecad868ddea1c7d24582a Mon Sep 17 00:00:00 2001 From: Thomas Weber Date: Wed, 1 Jan 2025 22:23:25 -0600 Subject: [PATCH 3/9] lazy: introduce notion of a lazy sprite --- src/engine/runtime.js | 86 +++++++++ src/extension-support/extension-manager.js | 5 +- src/extensions/tw_lazy/index.js | 92 +++++++++ src/serialization/sb2.js | 1 + src/serialization/sb3.js | 143 +++++++++----- src/sprites/tw-lazy-sprite.js | 148 +++++++++++++++ src/util/tw-cancellable-mutex.js | 79 ++++++++ src/virtual-machine.js | 19 +- .../tw-lazy-pen-used-only-in-lazy-sprite.sb3 | Bin 0 -> 2548 bytes test/fixtures/tw-lazy-simple.sb3 | Bin 0 -> 2527 bytes test/integration/tw_lazy.js | 176 ++++++++++++++++++ test/unit/tw_cancellable_mutex.js | 70 +++++++ 12 files changed, 765 insertions(+), 54 deletions(-) create mode 100644 src/extensions/tw_lazy/index.js create mode 100644 src/sprites/tw-lazy-sprite.js create mode 100644 src/util/tw-cancellable-mutex.js create mode 100644 test/fixtures/tw-lazy-pen-used-only-in-lazy-sprite.sb3 create mode 100644 test/fixtures/tw-lazy-simple.sb3 create mode 100644 test/integration/tw_lazy.js create mode 100644 test/unit/tw_cancellable_mutex.js diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 0f0c74a86f3..4b25719ee97 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -50,6 +50,8 @@ const defaultBlockPackages = { const interpolate = require('./tw-interpolate'); const FrameLoop = require('./tw-frame-loop'); +const LazySprite = require('../sprites/tw-lazy-sprite.js'); +const CancellableMutex = require('../util/tw-cancellable-mutex.js'); const defaultExtensionColors = ['#0FBD8C', '#0DA57A', '#0B8E69']; @@ -534,6 +536,18 @@ class Runtime extends EventEmitter { * Total number of finished or errored scratch-storage load() requests since the runtime was created or cleared. */ this.finishedAssetRequests = 0; + + /** + * Sprites with lazy-loading capabilities. + * @type {Array} + */ + this.lazySprites = []; + + /** + * All lazy sprite loading and unloading operations are gated behind this lock. + * @type {CancellableMutex} + */ + this.lazySpritesLock = new CancellableMutex(); } /** @@ -925,6 +939,22 @@ class Runtime extends EventEmitter { return 'PLATFORM_MISMATCH'; } + /** + * Event when lazily loaded sprites are loaded, before they have executed anything. + * Called with array of original targets. + */ + static get LAZY_SPRITES_LOADED () { + return 'LAZY_SPRITES_LOADED'; + } + + /** + * Event when lazily loaded sprites are unloaded. + * Called with array of targets, including clones. + */ + static get LAZY_SPRITES_UNLOADED () { + return 'LAZY_SPRITES_UNLOADED'; + } + /** * How rapidly we try to step threads by default, in ms. */ @@ -2276,6 +2306,14 @@ class Runtime extends EventEmitter { if (target.isOriginal) target.deleteMonitors(); }); + this.lazySpritesLock.cancel(); + this.lazySprites.forEach(sprite => { + if (sprite.state === LazySprite.State.LOADED || sprite.state === LazySprite.State.LOADING) { + sprite.unload(); + } + }); + this.lazySprites = []; + this.targets.map(this.disposeTarget, this); this.extensionStorage = {}; // tw: explicitly emit a MONITORS_UPDATE instead of relying on implicit behavior of _step() @@ -3482,6 +3520,54 @@ class Runtime extends EventEmitter { return callback().then(onSuccess, onError); } + + /** + * @param {string[]} spriteNames Assumed to contain no duplicate entries. + * @returns {Promise} Resolves when all sprites have been loaded. + */ + loadLazySprites (spriteNames) { + return this.lazySpritesLock.do(async isCancelled => { + const lazySprites = []; + for (const name of spriteNames) { + const lazySprite = this.lazySprites.find(sprite => sprite.name === name); + if (lazySprite) { + if (lazySprite.state === LazySprite.State.UNLOADED) { + lazySprites.push(lazySprite); + } else if (lazySprite.state === LazySprite.State.ERROR) { + // TODO(lazy) + } else { + // Already loaded or loading. Nothing to do. + } + } else { + throw new Error(`Unknown lazy sprite: ${spriteNames}`); + } + } + + const promises = lazySprites.map(sprite => sprite.load()); + const allTargets = await Promise.all(promises); + + if (isCancelled()) { + return; + } + + // Ignore cancelled targets. + const loadedTargets = allTargets.filter(i => i); + for (const target of loadedTargets) { + target.updateAllDrawableProperties(); + this.addTarget(target); + } + + this.emit(Runtime.LAZY_SPRITES_LOADED, loadedTargets); + return loadedTargets; + }); + } + + /** + * @param {string[]} spriteNames Assumed to contain no duplicate entries. + */ + unloadLazySprites (spriteNames) { + // TODO(lazy) + } } /** diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js index a092af35482..7a945c3318b 100644 --- a/src/extension-support/extension-manager.js +++ b/src/extension-support/extension-manager.js @@ -25,8 +25,9 @@ const defaultBuiltinExtensions = { makeymakey: () => require('../extensions/scratch3_makeymakey'), boost: () => require('../extensions/scratch3_boost'), gdxfor: () => require('../extensions/scratch3_gdx_for'), - // tw: core extension - tw: () => require('../extensions/tw') + // tw: core extensions + tw: () => require('../extensions/tw'), + twlazy: () => require('../extensions/tw_lazy') }; /** diff --git a/src/extensions/tw_lazy/index.js b/src/extensions/tw_lazy/index.js new file mode 100644 index 00000000000..67f8d17411b --- /dev/null +++ b/src/extensions/tw_lazy/index.js @@ -0,0 +1,92 @@ +const formatMessage = require('format-message'); +const BlockType = require('../../extension-support/block-type'); +const ArgumentType = require('../../extension-support/argument-type'); +const Cast = require('../../util/cast'); + +class LazySprites { + constructor (runtime) { + /** + * The runtime instantiating this block package. + * @type {Runtime} + */ + this.runtime = runtime; + } + + /** + * @returns {object} metadata for this extension and its blocks. + */ + getInfo () { + return { + id: 'twlazy', + name: 'Lazy Loading', + blocks: [ + { + blockType: BlockType.COMMAND, + opcode: 'loadSprite', + text: formatMessage({ + id: 'tw.lazy.loadSprite', + default: 'load sprite [SPRITE]', + description: 'Block that loads a sprite' + }), + arguments: { + SPRITE: { + type: ArgumentType.STRING, + menu: 'sprite' + } + } + } + ], + menus: { + sprite: { + acceptReporters: true, + items: 'getSpriteMenu' + }, + costume: { + acceptReporters: true, + items: 'getCostumeMenu' + }, + sound: { + acceptReporters: true, + items: 'getSoundMenu' + } + } + }; + } + + getSpriteMenu () { + if (this.runtime.lazySprites.length === 0) { + return [ + { + text: formatMessage({ + id: 'tw.lazy.noSprites', + default: 'No sprites', + description: 'Block menu in lazy loading extension when no lazy-loaded sprites exist' + }), + value: '' + } + ]; + } + + return this.runtime.lazySprites.map(i => i.name); + } + + getCostumeMenu () { + // TODO(lazy) + return ['b']; + } + + getSoundMenu () { + // TODO(lazy) + return ['c']; + } + + loadSprite (args) { + const name = Cast.toString(args.SPRITE); + return this.runtime.loadLazySprites([name]) + .catch(() => { + // TODO(lazy): handle this... + }); + } +} + +module.exports = LazySprites; diff --git a/src/serialization/sb2.js b/src/serialization/sb2.js index 1cde8e26e24..e1b0a4b1acb 100644 --- a/src/serialization/sb2.js +++ b/src/serialization/sb2.js @@ -928,6 +928,7 @@ const sb2import = function (json, runtime, optForceSprite, zip) { .then(reorderParsedTargets) .then(targets => ({ targets, + lazySprites: [], // lazy loading not supported in sb2 projects extensions })); }; diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index d6589ba8748..e029b7927bd 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -7,6 +7,7 @@ const Runtime = require('../engine/runtime'); const Blocks = require('../engine/blocks'); const Sprite = require('../sprites/sprite'); +const LazySprite = require('../sprites/tw-lazy-sprite.js'); const Variable = require('../engine/variable'); const Comment = require('../engine/comment'); const MonitorRecord = require('../engine/monitor-record'); @@ -1150,52 +1151,12 @@ const parseScratchAssets = function (object, runtime, zip) { }; /** - * 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. - * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. - * @param {JSZip} zip Sb3 file describing this project (to load assets from) - * @param {object} assets - Promises for assets of this scratch object grouped - * into costumes and sounds - * @return {!Promise.} Promise for the target created (stage or sprite), or null for unsupported objects. + * Loads data from a target's JSON to an actual Target object, in place. + * @param {Runtime} runtime + * @param {Target} target + * @param {object} object */ -const parseScratchObject = function (object, runtime, extensions, zip, assets) { - if (!Object.prototype.hasOwnProperty.call(object, 'name')) { - // Watcher/monitor - skip this object until those are implemented in VM. - // @todo - return Promise.resolve(null); - } - // Blocks container for this object. - const blocks = new Blocks(runtime); - - // @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.prototype.hasOwnProperty.call(object, 'name')) { - sprite.name = object.name; - } - if (Object.prototype.hasOwnProperty.call(object, 'blocks')) { - deserializeBlocks(object.blocks); - // Take a second pass to create objects and add extensions - for (const blockId in object.blocks) { - if (!Object.prototype.hasOwnProperty.call(object.blocks, blockId)) continue; - const blockJSON = object.blocks[blockId]; - blocks.createBlock(blockJSON); - - // If the block is from an extension, record it. - const extensionID = getExtensionIdForOpcode(blockJSON.opcode); - if (extensionID) { - extensions.extensionIDs.add(extensionID); - } - } - } - // Costumes from JSON. - const {costumePromises} = assets; - // Sounds from JSON - const {soundBank, soundPromises} = assets; - // Create the first clone, and load its run-state from JSON. - const target = sprite.createClone(object.isStage ? StageLayering.BACKGROUND_LAYER : StageLayering.SPRITE_LAYER); +const parseTargetStateFromJSON = function (runtime, target, object) { // Load target properties from JSON. if (Object.prototype.hasOwnProperty.call(object, 'tempo')) { target.tempo = object.tempo; @@ -1316,6 +1277,57 @@ const parseScratchObject = function (object, runtime, extensions, zip, assets) { if (Object.prototype.hasOwnProperty.call(object, 'extensionStorage')) { target.extensionStorage = object.extensionStorage; } +}; + +/** + * 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. + * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. + * @param {JSZip} zip Sb3 file describing this project (to load assets from) + * @param {object} assets - Promises for assets of this scratch object grouped + * into costumes and sounds + * @return {!Promise.} Promise for the target created (stage or sprite), or null for unsupported objects. + */ +const parseScratchObject = function (object, runtime, extensions, zip, assets) { + if (!Object.prototype.hasOwnProperty.call(object, 'name')) { + // Watcher/monitor - skip this object until those are implemented in VM. + // @todo + return Promise.resolve(null); + } + // Blocks container for this object. + const blocks = new Blocks(runtime); + + // @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.prototype.hasOwnProperty.call(object, 'name')) { + sprite.name = object.name; + } + if (Object.prototype.hasOwnProperty.call(object, 'blocks')) { + deserializeBlocks(object.blocks); + // Take a second pass to create objects and add extensions + for (const blockId in object.blocks) { + if (!Object.prototype.hasOwnProperty.call(object.blocks, blockId)) continue; + const blockJSON = object.blocks[blockId]; + blocks.createBlock(blockJSON); + + // If the block is from an extension, record it. + const extensionID = getExtensionIdForOpcode(blockJSON.opcode); + if (extensionID) { + extensions.extensionIDs.add(extensionID); + } + } + } + // Costumes from JSON. + const {costumePromises} = assets; + // Sounds from JSON + const {soundBank, soundPromises} = assets; + // Create the first clone, and load its run-state from JSON. + const target = sprite.createClone(object.isStage ? StageLayering.BACKGROUND_LAYER : StageLayering.SPRITE_LAYER); + // Load target properties from JSON. + parseTargetStateFromJSON(runtime, target, object); Promise.all(costumePromises).then(costumes => { sprite.costumes = costumes; }); @@ -1327,6 +1339,37 @@ const parseScratchObject = function (object, runtime, extensions, zip, assets) { return Promise.all(costumePromises.concat(soundPromises)).then(() => target); }; +/** + * @param {object} object Sprite's JSON + * @param {Runtime} runtime + * @param {ImportedExtensionsInfo} extensions Extension information + * @param {JSZip|null} zip Zip file, if any + * @returns {LazySprite} Sprite with lazy-loading capabilities + */ +const parseLazySprite = (object, runtime, extensions, zip) => { + const sprite = new LazySprite(runtime, object, zip); + const blocks = sprite.blocks; + + // See parseScratchObject above + if (Object.prototype.hasOwnProperty.call(object, 'name')) { + sprite.name = object.name; + } + if (Object.prototype.hasOwnProperty.call(object, 'blocks')) { + deserializeBlocks(object.blocks); + for (const blockId in object.blocks) { + if (!Object.prototype.hasOwnProperty.call(object.blocks, blockId)) continue; + const blockJSON = object.blocks[blockId]; + blocks.createBlock(blockJSON); + const extensionID = getExtensionIdForOpcode(blockJSON.opcode); + if (extensionID) { + extensions.extensionIDs.add(extensionID); + } + } + } + + return sprite; +}; + const deserializeMonitor = function (monitorData, runtime, targets, extensions) { // Monitors position is always stored as position from top-left corner in 480x360 stage. const xOffset = (runtime.stageWidth - 480) / 2; @@ -1585,8 +1628,14 @@ const deserialize = async function (json, runtime, zip, isSingleSprite) { runtime.extensionStorage = json.extensionStorage; } + const lazyTargets = targetObjects.filter(i => i.lazy === true); + const lazySprites = await Promise.all(lazyTargets.map(object => ( + parseLazySprite(object, runtime, extensions, zip) + ))); + return { targets, + lazySprites, extensions }; }; @@ -1598,5 +1647,7 @@ module.exports = { serializeBlocks: serializeBlocks, deserializeStandaloneBlocks: deserializeStandaloneBlocks, serializeStandaloneBlocks: serializeStandaloneBlocks, - getExtensionIdForOpcode: getExtensionIdForOpcode + getExtensionIdForOpcode: getExtensionIdForOpcode, + parseScratchAssets: parseScratchAssets, + parseTargetStateFromJSON: parseTargetStateFromJSON }; diff --git a/src/sprites/tw-lazy-sprite.js b/src/sprites/tw-lazy-sprite.js new file mode 100644 index 00000000000..612e93ec1a9 --- /dev/null +++ b/src/sprites/tw-lazy-sprite.js @@ -0,0 +1,148 @@ +const Sprite = require('./sprite'); + +/** + * @enum {'unloaded'|'loading'|'loaded'|'error'} + */ +const State = { + UNLOADED: 'unloaded', + LOADING: 'loading', + LOADED: 'loaded', + ERROR: 'error' +}; + +/** + * Sprite with lazy-loading capabilities. + */ +class LazySprite extends Sprite { + /** + * @param {Runtime} runtime + * @param {object} initialJSON + * @param {JSZip|null} initialZip + */ + constructor (runtime, initialJSON, initialZip) { + // null blocks means Sprite will create it for us + super(null, runtime); + + /** + * @type {State} + */ + this.state = State.UNLOADED; + + /** + * sprite.json or project.json targets[x] entry for the sprite. + * @type {object} + */ + this.object = initialJSON; + + /** + * @type {JSZip|null} + */ + this.zip = initialZip; + + /** + * Callback to cancel current load operation. + * @type {() => void} + */ + this._cancelLoadCallback = () => {}; + } + + /** + * Creates an instance of this sprite. + * State must be unloaded. + * Renderer state is not updated. You must call updateAllDrawableProperties() yourself later. + * @returns {Promise} Loaded target, or null if cancelled by unload() call. + */ + load () { + if (this.state !== State.UNLOADED) { + return Promise.reject(new Error(`Unknown state transition ${this.state} -> loading`)); + } + + let cancelled = false; + + const load = async () => { + this.state = State.LOADING; + + const sb3 = require('../serialization/sb3'); + const { + costumePromises, + soundPromises, + soundBank + } = sb3.parseScratchAssets(this.object, this.runtime, this.zip); + + // Wait a bit to give storage a chance to start requests. + await Promise.resolve(); + + // Need to check for cancellation after each async operation. + // At this point the promise is already finished, so our return value won't be seen anywhere. + if (cancelled) { + return null; + } + + const target = this.createClone(); + sb3.parseTargetStateFromJSON(this.runtime, target, this.object); + + const costumes = await Promise.all(costumePromises); + if (cancelled) { + return null; + } + + const sounds = await Promise.all(soundPromises); + if (cancelled) { + return null; + } + + this.costumes = costumes; + this.sounds = sounds; + this.soundBank = soundBank || null; + this.state = State.LOADED; + return target; + }; + + return new Promise((resolve, reject) => { + this._cancelLoadCallback = () => { + cancelled = true; + resolve(null); + }; + + load().then(resolve, reject); + }).catch(err => { + this.state = State.ERROR; + throw err; + }); + } + + /** + * Updates this sprite's stored state based on its original clone. Existing targets are not removed. + * State must be loaded. + * @returns {void} + */ + save () { + if (this.state !== State.LOADED) { + return Promise.reject(new Error(`Cannot save in state ${this.state}`)); + } + + // TODO(lazy) + const sb3 = require('../serialization/sb3'); + const serialized = sb3.serialize(); + } + + /** + * Updates this sprite's stored state based on its original clone, and removes existing targets. + * State must be LOADED. + * @returns {void} + */ + unload () { + if (this.state !== State.LOADED && this.state !== State.LOADING) { + return Promise.reject(new Error(`Unknown state transition ${this.state} -> unloaded`)); + } + + // TODO(lazy) + this.state = State.UNLOADED; + this._cancelLoadCallback(); + } +} + +// Export enums +LazySprite.State = State; + +module.exports = LazySprite; diff --git a/src/util/tw-cancellable-mutex.js b/src/util/tw-cancellable-mutex.js new file mode 100644 index 00000000000..242c29f2cf0 --- /dev/null +++ b/src/util/tw-cancellable-mutex.js @@ -0,0 +1,79 @@ +/** + * @template T + */ +class CancellableMutex { + constructor () { + /** + * True if the mutex is locked. + * @type {boolean} + * @private + */ + this._locked = false; + + /** + * Queued operations. + * @type {Array<(isCancelled: () => boolean) => Promise>} + */ + this._queue = []; + + /** + * @type {number} + */ + this._cancels = 0; + } + + /** + * Perform async operation using the lock. Will wait until the lock is available. + * @param {(isCancelled: () => boolean) => Promise} callback Async function to run. May resolve or reject. + * @returns {Promise} Resolves or rejects with value or error from callback. + */ + do (callback) { + return new Promise((resolve, reject) => { + const initialCancels = this._cancels; + const isCancelled = () => initialCancels !== this._cancels; + + const startNextOperation = () => { + if (isCancelled()) { + return; + } + + if (this._queue.length) { + const nextCallback = this._queue.shift(); + nextCallback(); + } else { + this._locked = false; + this._cancelCallback = null; + } + }; + + const handleResolve = value => { + resolve(value); + startNextOperation(); + }; + + const handleReject = error => { + reject(error); + startNextOperation(); + }; + + const run = () => { + callback(isCancelled).then(handleResolve, handleReject); + }; + + if (this._locked) { + this._queue.push(run); + } else { + this._locked = true; + run(); + } + }); + } + + cancel () { + this._cancels++; + this._locked = false; + this._queue = []; + } +} + +module.exports = CancellableMutex; diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 052f3288174..a12fc23ba4c 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -706,7 +706,7 @@ class VirtualMachine extends EventEmitter { return Promise.reject('Unable to verify Scratch Project version.'); }; return deserializePromise() - .then(({targets, extensions}) => { + .then(({targets, lazySprites, extensions}) => { if (typeof performance !== 'undefined') { performance.mark('scratch-vm-deserialize-end'); try { @@ -720,7 +720,7 @@ class VirtualMachine extends EventEmitter { log.error(e); } } - return this.installTargets(targets, extensions, true); + return this.installTargets(targets, lazySprites, extensions, true); }); } @@ -756,11 +756,12 @@ class VirtualMachine extends EventEmitter { /** * Install `deserialize` results: zero or more targets after the extensions (if any) used by those targets. * @param {Array.} targets - the targets to be installed + * @param {Array.} lazySprites - sprites that can be loaded lazily * @param {ImportedExtensionsInfo} extensions - metadata about extensions used by these targets * @param {boolean} wholeProject - set to true if installing a whole project, as opposed to a single sprite. * @returns {Promise} resolved once targets have been installed */ - async installTargets (targets, extensions, wholeProject) { + async installTargets (targets, lazySprites, extensions, wholeProject) { await this.extensionManager.allAsyncExtensionsLoaded(); targets = targets.filter(target => !!target); @@ -779,6 +780,12 @@ class VirtualMachine extends EventEmitter { delete target.layerOrder; }); + if (wholeProject) { + this.runtime.lazySprites = lazySprites; + } else { + this.runtime.lazySprites = this.runtime.lazySprites.concat(lazySprites); + } + // Select the first target for editing, e.g., the first sprite. if (wholeProject && (targets.length > 1)) { this.editingTarget = targets[1]; @@ -866,8 +873,8 @@ class VirtualMachine extends EventEmitter { const sb2 = require('./serialization/sb2'); return sb2.deserialize(sprite, this.runtime, true, zip) - .then(({targets, extensions}) => - this.installTargets(targets, extensions, false)); + .then(({targets, lazySprites, extensions}) => + this.installTargets(targets, lazySprites, extensions, false)); } /** @@ -881,7 +888,7 @@ class VirtualMachine extends EventEmitter { const sb3 = require('./serialization/sb3'); return sb3 .deserialize(sprite, this.runtime, zip, true) - .then(({targets, extensions}) => this.installTargets(targets, extensions, false)); + .then(({targets, lazySprites, extensions}) => this.installTargets(targets, lazySprites, extensions, false)); } /** diff --git a/test/fixtures/tw-lazy-pen-used-only-in-lazy-sprite.sb3 b/test/fixtures/tw-lazy-pen-used-only-in-lazy-sprite.sb3 new file mode 100644 index 0000000000000000000000000000000000000000..570aeea53381c36712d0b65b4514c04291e047bc GIT binary patch literal 2548 zcma)8c`zGj8;=kKQI$Blt)p(GEs=;+-Aa(E+OpAV`9ea8qo|wIt?H)kTU>PuT8&zY zmQ`eBQCCY9U8-o1qF8qS_`Yvvc4psqp67kvXXf|J{AQkaem@H{b`BT-0N@627oWYk z`=Nmn2Lu4_g8%@&qpGKuhYP{M=Yos3hkO1FHxGu2z`;;`)*F#z(+rKZeV{X5a70Gt z(niVM9}+`M4DvOa^6PH@PF~7G5U@p%4nw~s=8QM%@_ zTRb(cw7xHbyJcQV@-&%?pLIrH&J?`2@S(r+1Y;#MlLpJjdQ=aF^J6*>$i0EFZAg(8 zHyrCE-?^?TP|JGk1Mi$TnKE?3+4Eo9u~bQHcb2hDPg>r{9xzmyEUw=gjU2NZpJb5@1U^di`<~GDqRy2vA`uUV!&MLj6@mkj;HZK4 z&joK^C%3C+{1K9{>7cmrr(8XCh?gZM*XYn@)5sgWoz?WI@tc`fiK^Kir^nf>p(RYJ ze!r}~mzGVGP&m@Pp_wt7?XzKXHm#vJ{OSNh!#FC#`EDdeal>a(o3G^DfF-Hhn9^UG zTX-1WROi}AtK@yK-6Lwt{J@?qF7EN{{+xL}h|?H#z@d%)P)-KhV?|b3W@NzslUkK2 zOhz7k!Z_Z)NTJ~3ezxgLK65DpK=8N6T85`>-qt}A>HBSdNqxe^7 zR(o@yId^QxIwaMBsa1q{;pZ*)r@bdGk@=f!3f;$E&+jsygb*}sZ|565vmPF{9$wvU zJZw14%01X_ICU{s%c${P@hEM}he5Jltq%)N6E*!pf(fertY?eKgO|@E~Q@_7KD;piy(rWt&7w}PaFU(g4Kriv-b=Uv%37e?7FJEMWzPlbjqQS2tkTme6y zk`Qrbv1l{E^SaaN@Wpk+N>qNw3x71mD3kyKdHXJx4jb5=F~L+Z%@n1kxB5XQ*X?pV zub>~Re5+$AXcJ-ei3vGkCH8`7^$?%z2*c7^C2fLOJM`jWLV!eW?qa9;twK z((^wLGZPVzslp-Ho|j4ADAf45K@@M2RAAfQX5iUIJ400Zc1=n+3c@_Y3pDS&iE2w#$KPSgpisK}A!CH!=?Z{&U!zX$ zXf|=m7FNKz^^_Vy9XFo>O0yEV9OD>c@!%SOheOgMm_JcB=w($-I0rq}kUg?tq(nv% zhREf?j^{l)DXk0C=c`PYN|dqU7LV_Hcsc!dp2sz6(!ufehElQPd{<0=WmDj#w@OD* z8*4aVmTMu~lgXn{!ObLScUz{nl5^peM`v;EF3v+CMG%WA=9J`%$@K5%F{hXiMM?aMkwQ@b6Pl4PT(@xa_WGbo*YrKc^vogoZw_bK&mfi zy{9gZFn1kQSmB2Q@f2u*LlV%IbdoGz|(#ZDKX8dE}VqPO-Ey9ggslypvu>;yJGl7k8<65!L@*Z9$tyaOePsvIR z=62exM0+1>n-29G=%>?hJguqy!hBD*%H=+(k6y({h1ua2d0Rs+O8_jXjGgS1Z;!o2 zpEwzQWTx%(QI{ru^`p8{9kq1=u6N=68KjQgMI13g_8{=mOaFY&S1Uh(C>!X*q=^oF zeMzKpho$%9%CeycX)%eq2?8&_2b{&_steWp=v@GbNRfYSp+|+lx@Mo82*zch-kpQH zzR6rZ4}9*o^oSU6!!8_Tr#KhY>oX4>#OMfpJo_X3k&yjqIBem&qp0wBT#GJ_goEyE z`sf^XBupQIXM4B4CM7Mi$m|woY(O3Me|A7eg7W90gdHFM>xYh!Fu*_S&|kRY+6e-}l{f?m74Uo%i>i_dVx%&w0*y&v_mPTP|)0007_vuxn&b zM?8HjeTfqQc+Up_2<=aULxTK>K4BVup+SKYF7DZRCXm?0hI306c?Hjw9&7sP272#@ zwDG;MZb9uvyjSn5_yy)}#FfvjXDX+d-9Hc~1`OvsD(A|${FP2dNpa#WxiK15nA%}? z>T^wtzD+k1O9hL@RJYYGf!~FZ;Si92!mOwyUHY9&?NrdacN(7U#w%1`R{w89&qp&c zv{yy5(T{@WVYRF%yGKwuUU`)5L9Bz_?|tZWwt@>;5f6r%2^JsW%}p*;tVwOBq$1PZ z9W@Rr(k{KO7d5KxtfAEUK23*G6iF?uR{2ORz6X`{+#@4V)N2P4Lggl>vkJTjms>N{ zhnh~7R3c4#L((n8vZsbqnhq$g_u7ubYCWGKu>k>d`>5*<0cU} zCA(@M!)EA4&Oyyj9ZCuhFN-)czrH`MV{s7yj|#4k*^VlAqJ6kkb@A?V* zAI)BRg;Ec4!O5}$rXM0l#*k~K+N%?tA_1&RZpP-j#wYH?QuNCfC(XE~@ikGWn*_yP zXBs?xH{nLh4z;B4o-{8LM)jhgk9RyeAL7+AI4qDr=6NrJ)VL6Ze8<;^CG%mn1Z!|k zMnCqkAC7x@C{olJBRg++n^PJ{~)#JV&3+`oREG@JG*nuZv-eCLqJ4C+DAX@fcU;(kY)jD{@kC&T1cIfn-`w0Dn198n&4hgH*10p+ zjyx2D>8oxn8aC5o_}}tq`E`n7OG?>T-`BIHm*dIyb9`EdW zE&6Udl(!62E&NzonZ7u>H5lPL^lfZxJM zo6%VbK&84(=y??cW>7K?mIIZ)*-X!N3B~3 zWXUW>%+yZEtb4wgxfO7nvAlF3bunf~Upl%4BRt>k{Bt_4*u#}bToTKCFgB+GnhF*Q0)P{-DZ{I_sD-U6e?#wC zQ~o##OjWtWV)JkUee3q2qdGejaNHc-^DUV->Zz2BFn zW5aGFnOEFVGa|w|z?vVE3UwfX87YKTt*?d{fYLq9QBg%@GmKi)%##(kE0ymJQMe0Y zV>USp%`|f5>9$tV5K%H`$}y9D2X8yhb@AsXt6+VPMXEz=B}El#@Mw-lx8+aKjy`RK zdgJnL&VS8_d|^ic%K@c}(mSrd9$Qu7Ye*GAf5#xWaddjO`w~CHn`5*yR;zalSI%9Q z5S&=1fALkyLyE4q2S*{D-W3UA5JN z;fh2234j1MA}B^U%jEL2n&L$6;WTruFehTise}1*D1)r;@xm>79ZqZ^}D&4?KS<;m(sNKCa*e zBULzAiP|yy%;VwTg>nk72dFs#7#I0`AXFO(kdm)HOh?!RdnzD`9c%~kIBpH~eUo&= zTfX={Pp$~Qh@bUied@(C2t=79CH9AoTnk!L_mV?ZnK2S!CiPRxXdS;)6M^Y za4E|XZR6H`-!&XFfhp#{~q2jlauWSLwOeYPDX zi>K;}*Pje61104uzjQd(41#n`)r!R6uVW?;qb|HE`l8PHC}J*?6m`lo5$LHpozfFF z10Jw87XKi75|t_LC5?j2ZWEvqtc?36cz-;2b2ZF(@5#0CJ^rblweQ*4UpB~G4z?Ve z#$5kwcJ>A3&!8v#D*tPJejy=%f7YSDaKC=qzIpmLs`U4be;0xOZVclB0RD|6IM@Pt SexbPb-{gLs-?wl8z<&TLoK}|r literal 0 HcmV?d00001 diff --git a/test/integration/tw_lazy.js b/test/integration/tw_lazy.js new file mode 100644 index 00000000000..5871382e036 --- /dev/null +++ b/test/integration/tw_lazy.js @@ -0,0 +1,176 @@ +const {test} = require('tap'); +const path = require('path'); +const fs = require('fs'); +const nodeCrypto = require('crypto'); +const VM = require('../../src/virtual-machine'); +const FakeRenderer = require('../fixtures/fake-renderer'); +const makeTestStorage = require('../fixtures/make-test-storage'); +const LazySprite = require('../../src/sprites/tw-lazy-sprite'); + +test('lazy loaded sprite inside a zip', t => { + const vm = new VM(); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-simple.sb3')); + + const loadedMd5Sums = new Set(); + const renderer = new FakeRenderer(); + renderer.createSVGSkin = function (svgData) { + const md5sum = nodeCrypto + .createHash('md5') + .update(svgData) + .digest('hex'); + + loadedMd5Sums.add(md5sum); + return this._nextSkinId++; + }; + + vm.attachRenderer(renderer); + vm.attachStorage(makeTestStorage()); + + vm.loadProject(fixture).then(() => { + t.equal(vm.runtime.targets.length, 1); + + t.equal(vm.runtime.lazySprites.length, 1); + const lazySprite = vm.runtime.lazySprites[0]; + t.equal(lazySprite.name, 'Sprite1'); + + t.equal(lazySprite.object.name, 'Sprite1'); + t.not(lazySprite.zip, null); + + t.not(loadedMd5Sums.has('927d672925e7b99f7813735c484c6922')); + + lazySprite.load().then(target => { + // Ensure sprite pointer matches + t.equal(target.sprite, lazySprite); + + // Make sure costume got passed to renderer + t.ok(loadedMd5Sums.has('927d672925e7b99f7813735c484c6922')); + + // Make sure various properties from JSON got copied + t.equal(target.getName(), 'Sprite1'); + t.equal(target.x, 10); + t.equal(target.y, 20); + t.equal(target.direction, 95); + t.equal(target.size, 101); + t.equal(target.draggable, true); + + t.end(); + }); + }); +}); + +test('unload before load finishes', t => { + const vm = new VM(); + const renderer = new FakeRenderer(); + vm.attachRenderer(renderer); + vm.attachStorage(makeTestStorage()); + + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-simple.sb3')); + vm.loadProject(fixture).then(() => { + const lazySprite = vm.runtime.lazySprites[0]; + + lazySprite.load().then(target => { + t.equal(target, null); + t.end(); + }); + lazySprite.unload(); + }); +}); + +test('eagerly imports extensions used only inside lazy sprite', t => { + const vm = new VM(); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-pen-used-only-in-lazy-sprite.sb3')); + vm.loadProject(fixture).then(() => { + // Make sure pen extension got loaded + t.equal(vm.runtime._blockInfo[0].id, 'pen'); + + // And make sure that the sprite actually loads. + const lazySprite = vm.runtime.lazySprites[0]; + lazySprite.load().then(target => { + t.equal(target.getName(), 'Sprite1'); + t.end(); + }); + }); +}); + +test('invalid LazySprite.load() state transitions', t => { + t.plan(4); + const vm = new VM(); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-simple.sb3')); + vm.loadProject(fixture).then(() => { + const lazySprite = vm.runtime.lazySprites[0]; + + // This load runs first, should succeed + lazySprite.load().then(target => { + t.equal(target.getName(), 'Sprite1'); + + // Third load. Should fail. + lazySprite.load().catch(err => { + t.equal(err.message, 'Unknown state transition loaded -> loading'); + + // Mock the error state. load() should fail. + lazySprite.state = LazySprite.State.ERROR; + lazySprite.load().catch(err2 => { + t.equal(err2.message, 'Unknown state transition error -> loading'); + t.end(); + }); + }); + }); + + // Second load. Should fail. + lazySprite.load().catch(err => { + t.equal(err.message, 'Unknown state transition loading -> loading'); + }); + }); +}); + +test('LazySprite.load() handles error', t => { + const vm = new VM(); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-simple.sb3')); + vm.loadProject(fixture).then(() => { + const lazySprite = vm.runtime.lazySprites[0]; + lazySprite.createClone = () => { + throw new Error('Simulated error to test error handling'); + }; + + lazySprite.load().catch(err => { + // Make sure it is the expected simulated error, not a real error + t.equal(err.message, 'Simulated error to test error handling'); + + t.equal(lazySprite.state, LazySprite.State.ERROR); + t.end(); + }); + }); +}); + +test('lazy sprites removed on dispose', t => { + const vm = new VM(); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-simple.sb3')); + vm.loadProject(fixture).then(() => { + t.equal(vm.runtime.lazySprites.length, 1); + vm.runtime.dispose(); + t.equal(vm.runtime.lazySprites.length, 0); + t.end(); + }); +}); + +test('dispose cancels current load operations', t => { + const vm = new VM(); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-simple.sb3')); + vm.loadProject(fixture).then(() => { + const lazySprite = vm.runtime.lazySprites[0]; + lazySprite.load().then(target => { + t.equal(target, null); + t.end(); + }); + vm.runtime.dispose(); + }); +}); + +test('sb2 has no lazy sprites', t => { + const vm = new VM(); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/default.sb2')); + vm.loadProject(fixture).then(() => { + t.equal(vm.runtime.lazySprites.length, 0); + t.end(); + }); +}); diff --git a/test/unit/tw_cancellable_mutex.js b/test/unit/tw_cancellable_mutex.js new file mode 100644 index 00000000000..7125f571667 --- /dev/null +++ b/test/unit/tw_cancellable_mutex.js @@ -0,0 +1,70 @@ +const {test} = require('tap'); +const CancellableMutex = require('../../src/util/tw-cancellable-mutex'); + +test('basic queing', t => { + const mutex = new CancellableMutex(); + const events = []; + + // Even though this operation takes the longest, because they are run sequentially, + // should finish first. + mutex.do(() => new Promise(resolve => { + setTimeout(() => resolve(5), 100); + })).then(value => { + // Make sure resolved value passes through transparently + t.equal(value, 5); + events.push(1); + }); + + // Tests rejection and instant finishing. + mutex.do(() => new Promise((resolve, reject) => { + reject(new Error('Test error')); + })).catch(error => { + // Make sure rejection reason passes through transparently + t.equal(error.message, 'Test error'); + events.push(2); + }); + + mutex.do(() => new Promise(resolve => { + setTimeout(() => { + resolve(); + + // At this point the queue of operations is now empty. Make sure it can + // resume operations from this state. + setTimeout(() => { + mutex.do(() => new Promise(resolve2 => { + resolve2(); + })).then(() => { + events.push(4); + t.same(events, [1, 2, 3, 4]); + t.end(); + }); + }); + }, 50); + })).then(() => { + events.push(3); + }); +}); + +test('cancellation', t => { + const mutex = new CancellableMutex(); + + // Start operation that should never end and then grab its cancel checker. + let isCancelled = null; + mutex.do(_isCancelled => new Promise(() => { + isCancelled = _isCancelled; + })); + t.equal(isCancelled(), false); + + // This operation should never run. + mutex.do(() => new Promise(() => { + t.fail(); + })); + + // After dispoing, existing operation should be cancelled, queue should be cleared. + mutex.cancel(); + t.equal(isCancelled(), true); + + mutex.do(() => new Promise(() => { + t.end(); + })); +}); From 27a36b24238910e4d98ee38bc11474f32c59aa50 Mon Sep 17 00:00:00 2001 From: Thomas Weber Date: Wed, 1 Jan 2025 22:38:07 -0600 Subject: [PATCH 4/9] lazy: implement "when I load" block --- src/extensions/tw_lazy/index.js | 62 +++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/src/extensions/tw_lazy/index.js b/src/extensions/tw_lazy/index.js index 67f8d17411b..94930d63a9e 100644 --- a/src/extensions/tw_lazy/index.js +++ b/src/extensions/tw_lazy/index.js @@ -2,6 +2,7 @@ const formatMessage = require('format-message'); const BlockType = require('../../extension-support/block-type'); const ArgumentType = require('../../extension-support/argument-type'); const Cast = require('../../util/cast'); +const Runtime = require('../../engine/runtime'); class LazySprites { constructor (runtime) { @@ -10,6 +11,14 @@ class LazySprites { * @type {Runtime} */ this.runtime = runtime; + + // This is implemented with an event rather than in the blocks below so that it works if + // some other extension loads sprites lazily. + this.runtime.on(Runtime.LAZY_SPRITES_LOADED, targets => { + for (const target of targets) { + this.runtime.startHats('twlazy_whenLoaded', null, target); + } + }); } /** @@ -20,6 +29,16 @@ class LazySprites { id: 'twlazy', name: 'Lazy Loading', blocks: [ + { + blockType: BlockType.EVENT, + opcode: 'whenLoaded', + isEdgeActivated: false, + text: formatMessage({ + id: 'tw.lazy.whenLoaded', + default: 'when I load', + description: 'Hat block that runs when a sprite loads' + }) + }, { blockType: BlockType.COMMAND, opcode: 'loadSprite', @@ -31,29 +50,44 @@ class LazySprites { arguments: { SPRITE: { type: ArgumentType.STRING, - menu: 'sprite' + menu: 'lazySprite' + } + } + }, + { + blockType: BlockType.COMMAND, + opcode: 'unloadSprite', + text: formatMessage({ + id: 'tw.lazy.unloadSprite', + default: 'unload sprite [SPRITE]', + description: 'Block that unloads a sprite' + }), + arguments: { + SPRITE: { + type: ArgumentType.STRING, + menu: 'lazySprite' } } } ], menus: { - sprite: { + lazySprite: { acceptReporters: true, - items: 'getSpriteMenu' + items: 'getLazySpritesMenu' }, - costume: { + lazyCostume: { acceptReporters: true, - items: 'getCostumeMenu' + items: 'getLazyCostumesMenu' }, - sound: { + lazySound: { acceptReporters: true, - items: 'getSoundMenu' + items: 'getLazySoundsMenu' } } }; } - getSpriteMenu () { + getLazySpritesMenu () { if (this.runtime.lazySprites.length === 0) { return [ { @@ -70,12 +104,12 @@ class LazySprites { return this.runtime.lazySprites.map(i => i.name); } - getCostumeMenu () { + getLazyCostumesMenu () { // TODO(lazy) return ['b']; } - getSoundMenu () { + getLazySoundsMenu () { // TODO(lazy) return ['c']; } @@ -87,6 +121,14 @@ class LazySprites { // TODO(lazy): handle this... }); } + + unloadSprite (args) { + const name = Cast.toString(args.SPRITE); + return this.runtime.unloadLazySprites([name]) + .catch(() => { + // TODO(lazy): handle this... + }); + } } module.exports = LazySprites; From a545db42afc54337f7ff20ec44154d73218d8bca Mon Sep 17 00:00:00 2001 From: Thomas Weber Date: Wed, 1 Jan 2025 22:42:43 -0600 Subject: [PATCH 5/9] lazy: support syncronous functions in cancellable mutex --- src/util/tw-cancellable-mutex.js | 8 ++++-- test/unit/tw_cancellable_mutex.js | 46 +++++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/util/tw-cancellable-mutex.js b/src/util/tw-cancellable-mutex.js index 242c29f2cf0..df76745183f 100644 --- a/src/util/tw-cancellable-mutex.js +++ b/src/util/tw-cancellable-mutex.js @@ -56,8 +56,12 @@ class CancellableMutex { startNextOperation(); }; - const run = () => { - callback(isCancelled).then(handleResolve, handleReject); + const run = async () => { + try { + handleResolve(await callback(isCancelled)); + } catch (error) { + handleReject(error); + } }; if (this._locked) { diff --git a/test/unit/tw_cancellable_mutex.js b/test/unit/tw_cancellable_mutex.js index 7125f571667..682ca325db5 100644 --- a/test/unit/tw_cancellable_mutex.js +++ b/test/unit/tw_cancellable_mutex.js @@ -5,8 +5,7 @@ test('basic queing', t => { const mutex = new CancellableMutex(); const events = []; - // Even though this operation takes the longest, because they are run sequentially, - // should finish first. + // Tests long resolved promise. mutex.do(() => new Promise(resolve => { setTimeout(() => resolve(5), 100); })).then(value => { @@ -15,15 +14,44 @@ test('basic queing', t => { events.push(1); }); - // Tests rejection and instant finishing. + // Tests long rejected promise. mutex.do(() => new Promise((resolve, reject) => { - reject(new Error('Test error')); + setTimeout(() => reject(new Error('Test error 1')), 100); })).catch(error => { - // Make sure rejection reason passes through transparently - t.equal(error.message, 'Test error'); + t.equal(error.message, 'Test error 1'); events.push(2); }); + + // Tests instantly-resolving resolved promise. + mutex.do(() => new Promise(resolve => { + resolve(10); + })).then(value => { + t.equal(value, 10); + events.push(3); + }); + + // Tests instantly-resolving rejected promise. + mutex.do(() => new Promise((resolve, reject) => { + reject(new Error('Test error 2')); + })).catch(error => { + t.equal(error.message, 'Test error 2'); + events.push(4); + }); + + // Tests instantly-returning sync function. + mutex.do(() => 15).then(value => { + t.equal(value, 15); + events.push(5); + }); + // Tests instantly-throwing sync function. + mutex.do(() => { + throw new Error('Test error 3'); + }).catch(error => { + t.equal(error.message, 'Test error 3'); + events.push(6); + }); + mutex.do(() => new Promise(resolve => { setTimeout(() => { resolve(); @@ -34,14 +62,14 @@ test('basic queing', t => { mutex.do(() => new Promise(resolve2 => { resolve2(); })).then(() => { - events.push(4); - t.same(events, [1, 2, 3, 4]); + events.push(8); + t.same(events, [1, 2, 3, 4, 5, 6, 7, 8]); t.end(); }); }); }, 50); })).then(() => { - events.push(3); + events.push(7); }); }); From 0246655d6d42304a38edd401ba7c964d8a50247e Mon Sep 17 00:00:00 2001 From: Thomas Weber Date: Wed, 1 Jan 2025 23:36:23 -0600 Subject: [PATCH 6/9] lazy: implement sprite unloading --- src/engine/runtime.js | 35 +++++++---- src/sprites/tw-lazy-sprite.js | 111 +++++++++++++++++++++++++++++++--- src/util/tw-asset-util.js | 13 +++- 3 files changed, 137 insertions(+), 22 deletions(-) diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 4b25719ee97..af1cbe13834 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -947,14 +947,6 @@ class Runtime extends EventEmitter { return 'LAZY_SPRITES_LOADED'; } - /** - * Event when lazily loaded sprites are unloaded. - * Called with array of targets, including clones. - */ - static get LAZY_SPRITES_UNLOADED () { - return 'LAZY_SPRITES_UNLOADED'; - } - /** * How rapidly we try to step threads by default, in ms. */ @@ -3539,10 +3531,10 @@ class Runtime extends EventEmitter { // Already loaded or loading. Nothing to do. } } else { - throw new Error(`Unknown lazy sprite: ${spriteNames}`); + throw new Error(`Unknown lazy sprite: ${name}`); } } - + const promises = lazySprites.map(sprite => sprite.load()); const allTargets = await Promise.all(promises); @@ -3564,9 +3556,30 @@ class Runtime extends EventEmitter { /** * @param {string[]} spriteNames Assumed to contain no duplicate entries. + * @returns {Promise} Resolves when all sprites have been unloaded. */ unloadLazySprites (spriteNames) { - // TODO(lazy) + return this.lazySpritesLock.do(() => { + const lazySprites = []; + for (const name of spriteNames) { + const lazySprite = this.lazySprites.find(sprite => sprite.name === name); + if (lazySprite) { + if (lazySprite.state === LazySprite.State.LOADED || lazySprite.state === LazySprite.State.LOADING) { + lazySprites.push(lazySprite); + } else if (lazySprite.state === LazySprite.State.ERROR) { + // TODO(lazy) + } else { + // Already unloaded. Nothing to do. + } + } else { + throw new Error(`Unknown lazy sprite: ${name}`); + } + } + + for (const lazySprite of lazySprites) { + lazySprite.unload(); + } + }); } } diff --git a/src/sprites/tw-lazy-sprite.js b/src/sprites/tw-lazy-sprite.js index 612e93ec1a9..f174eb74e17 100644 --- a/src/sprites/tw-lazy-sprite.js +++ b/src/sprites/tw-lazy-sprite.js @@ -1,4 +1,5 @@ const Sprite = require('./sprite'); +const JSZip = require('@turbowarp/jszip'); /** * @enum {'unloaded'|'loading'|'loaded'|'error'} @@ -10,14 +11,84 @@ const State = { ERROR: 'error' }; +/** + * TODO(lazy): this will generally just leak memory over time in the editor. + */ +class AssetCache { + constructor () { + /** + * Created lazily. + * @type {JSZip|null} + */ + this.internalCache = new JSZip(); + + /** + * @type {JSZip[]} + */ + this.zips = [this.internalCache]; + } + + /** + * @param {JSZip} zip + */ + addZip (zip) { + if (!this.zips.includes(zip)) { + this.zips.push(zip); + } + } + + /** + * Cache a file if it is not already cached. + * @param {string} md5ext md5 and extension + * @param {Uint8Array} data in-memory data + */ + storeIfMissing (md5ext, data) { + if (this.file(md5ext)) { + // Already cached. + return; + } + + // TODO(lazy): we may be able to alleviate memory issues from this if we convert to Blob + // since those can at least theoretically be stored on disk. We might want to do that in + // some sort of background timer so we don't overload everything trying to convert 100MB+ + // of data in one go. + this.internalCache.file(md5ext, data); + } + + /** + * Allows this class to be used as a JSZip zip. + * @param {string} md5ext md5 and extension + * @param {unknown} data Do not provide + * @returns {JSZip.JSZipObject|null} JSZip file if it exists. + */ + file (md5ext, data) { + if (data) { + // There is already a specific method for this. + throw new Error('AssetCache.file() does not support modification'); + } + + for (const zip of this.zips) { + const file = zip.file(md5ext); + // TODO(lazy): check subfolders to match how the other asset operations work + if (file) { + return file; + } + } + + return null; + } +} + +const assetCacheSingleton = new AssetCache(); + /** * Sprite with lazy-loading capabilities. */ class LazySprite extends Sprite { /** * @param {Runtime} runtime - * @param {object} initialJSON - * @param {JSZip|null} initialZip + * @param {object} initialJSON JSON from project.json or sprite.json + * @param {JSZip|null} initialZip Zip file provided when loading the project. */ constructor (runtime, initialJSON, initialZip) { // null blocks means Sprite will create it for us @@ -34,10 +105,9 @@ class LazySprite extends Sprite { */ this.object = initialJSON; - /** - * @type {JSZip|null} - */ - this.zip = initialZip; + if (initialZip) { + assetCacheSingleton.addZip(initialZip); + } /** * Callback to cancel current load operation. @@ -67,7 +137,7 @@ class LazySprite extends Sprite { costumePromises, soundPromises, soundBank - } = sb3.parseScratchAssets(this.object, this.runtime, this.zip); + } = sb3.parseScratchAssets(this.object, this.runtime, assetCacheSingleton); // Wait a bit to give storage a chance to start requests. await Promise.resolve(); @@ -121,9 +191,20 @@ class LazySprite extends Sprite { return Promise.reject(new Error(`Cannot save in state ${this.state}`)); } - // TODO(lazy) const sb3 = require('../serialization/sb3'); - const serialized = sb3.serialize(); + const serializeAssets = require('../serialization/serialize-assets'); + + const target = this.clones[0]; + const serializedJSON = sb3.serialize(this.runtime, target.id); + const assets = [ + ...serializeAssets.serializeCostumes(this.runtime, target.id), + ...serializeAssets.serializeSounds(this.runtime, target.id) + ]; + + this.object = serializedJSON; + for (const asset of assets) { + assetCacheSingleton.storeIfMissing(asset.fileName, asset.fileContent); + } } /** @@ -136,9 +217,19 @@ class LazySprite extends Sprite { return Promise.reject(new Error(`Unknown state transition ${this.state} -> unloaded`)); } - // TODO(lazy) + // Only save if we're in the loaded state. If we're in the loading state, we will have nothing + // to save in the first place. + if (this.state === State.LOADED) { + this.save(); + } + this.state = State.UNLOADED; this._cancelLoadCallback(); + + for (const target of this.clones) { + this.runtime.requestTargetsUpdate(target); + this.runtime.disposeTarget(target); + } } } diff --git a/src/util/tw-asset-util.js b/src/util/tw-asset-util.js index 380d9fefb82..bd6f09a6867 100644 --- a/src/util/tw-asset-util.js +++ b/src/util/tw-asset-util.js @@ -3,7 +3,7 @@ const StringUtil = require('./string-util'); class AssetUtil { /** * @param {Runtime} runtime runtime with storage attached - * @param {JSZip} zip optional JSZip to search for asset in + * @param {JSZip|null} zip optional JSZip to search for asset in * @param {Storage.assetType} assetType scratch-storage asset type * @param {string} md5ext full md5 with file extension * @returns {Promise} scratch-storage asset object @@ -37,6 +37,17 @@ class AssetUtil { return runtime.wrapAssetRequest(() => runtime.storage.load(assetType, md5, ext)); } + + /** + * + * @param {JSZip} zip Zip to search + * @param {Storage.assetType} assetType scratch-storage asset type + * @param {string} md5ext full md5 with file extension + * @returns {boolean} + */ + static md5ExtExists (zip, assetType, md5ext) { + + } } module.exports = AssetUtil; From 2822b6980cec4c847df739e5d9babacb32e98338 Mon Sep 17 00:00:00 2001 From: Thomas Weber Date: Thu, 2 Jan 2025 00:25:59 -0600 Subject: [PATCH 7/9] lazy: export lazy sprites in json --- src/serialization/sb3.js | 39 ++++++++++++++++++++-- src/sprites/rendered-target.js | 4 +-- src/sprites/sprite.js | 8 +++++ src/sprites/tw-lazy-sprite.js | 8 ++++- test/integration/tw_lazy.js | 61 +++++++++++++++++++++++++++++++++- test/integration/tw_sprite.js | 9 +++++ 6 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 test/integration/tw_sprite.js diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index e029b7927bd..d27b1737c6e 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -629,6 +629,11 @@ const serializeTarget = function (target, extensions) { obj.rotationStyle = target.rotationStyle; } + // Only output anything for lazy sprites so that we match vanilla for non-lazy sprites. + if (target.lazy) { + obj.lazy = true; + } + // Add found extensions to the extensions object targetExtensions.forEach(extensionId => { extensions.add(extensionId); @@ -636,6 +641,24 @@ const serializeTarget = function (target, extensions) { return obj; }; +/** + * @param {LazySprite} lazySprite + * @param {Set} extensions + * @returns {object} + */ +const serializeLazySprite = function (lazySprite, extensions) { + if (lazySprite.state === LazySprite.State.LOADED) { + lazySprite.save(); + } + + const [_blocks, targetExtensions] = serializeBlocks(lazySprite.object.blocks); + targetExtensions.forEach(extensionId => { + extensions.add(extensionId); + }); + + return lazySprite.object; +}; + /** * @param {Record} extensionStorage extensionStorage object * @param {Set} extensions extension IDs @@ -720,7 +743,8 @@ const serialize = function (runtime, targetId, {allowOptimization = true} = {}) const originalTargetsToSerialize = targetId ? [runtime.getTargetById(targetId)] : - runtime.targets.filter(target => target.isOriginal); + runtime.targets.filter(target => target.isOriginal && !target.sprite.isLazy); + const lazySpritesToSerialize = targetId ? [] : runtime.lazySprites; const layerOrdering = getSimplifiedLayerOrdering(originalTargetsToSerialize); @@ -745,10 +769,17 @@ const serialize = function (runtime, targetId, {allowOptimization = true} = {}) return serialized; }); + const serializedLazySprites = lazySpritesToSerialize.map(s => serializeLazySprite(s, extensions)); + const fonts = runtime.fontManager.serializeJSON(); if (targetId) { const target = serializedTargets[0]; + + // Doesn't make sense for an export of a single sprite to be lazy when it gets + // imported again. + delete target.lazy; + if (extensions.size) { // Vanilla Scratch doesn't include extensions in sprites, so don't add this if it's not needed target.extensions = Array.from(extensions); @@ -768,7 +799,10 @@ const serialize = function (runtime, targetId, {allowOptimization = true} = {}) obj.extensionStorage = globalExtensionStorage; } - obj.targets = serializedTargets; + obj.targets = [ + ...serializedTargets, + ...serializedLazySprites + ]; obj.monitors = serializeMonitors(runtime.getMonitorState(), runtime, extensions); @@ -1645,6 +1679,7 @@ module.exports = { deserialize: deserialize, deserializeBlocks: deserializeBlocks, serializeBlocks: serializeBlocks, + serializeTarget: serializeTarget, deserializeStandaloneBlocks: deserializeStandaloneBlocks, serializeStandaloneBlocks: serializeStandaloneBlocks, getExtensionIdForOpcode: getExtensionIdForOpcode, diff --git a/src/sprites/rendered-target.js b/src/sprites/rendered-target.js index eeb029f3874..fa5898c645d 100644 --- a/src/sprites/rendered-target.js +++ b/src/sprites/rendered-target.js @@ -1096,8 +1096,8 @@ class RenderedTarget extends Target { tempo: this.tempo, volume: this.volume, videoTransparency: this.videoTransparency, - videoState: this.videoState - + videoState: this.videoState, + lazy: this.sprite.isLazy }; } diff --git a/src/sprites/sprite.js b/src/sprites/sprite.js index a38fa129c60..1016bf8676f 100644 --- a/src/sprites/sprite.js +++ b/src/sprites/sprite.js @@ -56,6 +56,14 @@ class Sprite { } } + /** + * True if this sprite uses lazy loading. + * @type {boolean} + */ + get isLazy () { + return false; + } + /** * Add an array of costumes, taking care to avoid duplicate names. * @param {!Array} costumes Array of objects representing costumes. diff --git a/src/sprites/tw-lazy-sprite.js b/src/sprites/tw-lazy-sprite.js index f174eb74e17..b27848de521 100644 --- a/src/sprites/tw-lazy-sprite.js +++ b/src/sprites/tw-lazy-sprite.js @@ -116,6 +116,10 @@ class LazySprite extends Sprite { this._cancelLoadCallback = () => {}; } + get isLazy () { + return true; + } + /** * Creates an instance of this sprite. * State must be unloaded. @@ -132,6 +136,7 @@ class LazySprite extends Sprite { const load = async () => { this.state = State.LOADING; + // Loaded lazily to avoid circular dependencies const sb3 = require('../serialization/sb3'); const { costumePromises, @@ -195,7 +200,8 @@ class LazySprite extends Sprite { const serializeAssets = require('../serialization/serialize-assets'); const target = this.clones[0]; - const serializedJSON = sb3.serialize(this.runtime, target.id); + const extensions = new Set(); + const serializedJSON = sb3.serializeTarget(target.toJSON(), extensions); const assets = [ ...serializeAssets.serializeCostumes(this.runtime, target.id), ...serializeAssets.serializeSounds(this.runtime, target.id) diff --git a/test/integration/tw_lazy.js b/test/integration/tw_lazy.js index 5871382e036..bb9249cd1f9 100644 --- a/test/integration/tw_lazy.js +++ b/test/integration/tw_lazy.js @@ -2,6 +2,7 @@ const {test} = require('tap'); const path = require('path'); const fs = require('fs'); const nodeCrypto = require('crypto'); +const JSZip = require('@turbowarp/jszip'); const VM = require('../../src/virtual-machine'); const FakeRenderer = require('../fixtures/fake-renderer'); const makeTestStorage = require('../fixtures/make-test-storage'); @@ -36,7 +37,7 @@ test('lazy loaded sprite inside a zip', t => { t.equal(lazySprite.object.name, 'Sprite1'); t.not(lazySprite.zip, null); - t.not(loadedMd5Sums.has('927d672925e7b99f7813735c484c6922')); + t.notOk(loadedMd5Sums.has('927d672925e7b99f7813735c484c6922')); lazySprite.load().then(target => { // Ensure sprite pointer matches @@ -58,6 +59,16 @@ test('lazy loaded sprite inside a zip', t => { }); }); +test('isLazy === true', t => { + const vm = new VM(); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-simple.sb3')); + vm.loadProject(fixture).then(() => { + const lazySprite = vm.runtime.lazySprites[0]; + t.equal(lazySprite.isLazy, true); + t.end(); + }); +}); + test('unload before load finishes', t => { const vm = new VM(); const renderer = new FakeRenderer(); @@ -174,3 +185,51 @@ test('sb2 has no lazy sprites', t => { t.end(); }); }); + +for (const load of [true, false]) { + test(`export lazy sprites ${load ? 'after' : 'before'} loading`, t => { + const vm = new VM(); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-simple.sb3')); + + vm.loadProject(fixture).then(async () => { + if (load) { + await vm.runtime.loadLazySprites(['Sprite1']); + } + + const buffer = await vm.saveProjectSb3('arraybuffer'); + const zip = await JSZip.loadAsync(buffer); + const json = JSON.parse(await zip.file('project.json').async('text')); + + // Surface-level checks + t.equal(json.targets.length, 2); + t.notOk(Object.prototype.hasOwnProperty.call(json.targets[0], 'lazy')); + t.equal(json.targets[1].name, 'Sprite1'); + t.equal(json.targets[1].lazy, true); + + // Expect exact equality of target JSON + const fixtureZip = await JSZip.loadAsync(fixture); + const fixtureJSON = JSON.parse(await fixtureZip.file('project.json').async('text')); + + delete json.targets[1].targetPaneOrder; + delete fixtureJSON.targets[1].targetPaneOrder; + delete json.targets[1].layerOrder; + delete fixtureJSON.targets[1].layerOrder; + + t.same(json.targets[1], fixtureJSON.targets[1]); + + t.end(); + }); + }); +} + +test('lazy sprite is not lazy when exported individually', t => { + const vm = new VM(); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-simple.sb3')); + vm.loadProject(fixture).then(() => { + vm.runtime.loadLazySprites(['Sprite1']).then(([target]) => { + const json = JSON.parse(vm.toJSON(target.id)); + t.notOk(Object.prototype.hasOwnProperty.call(json, 'lazy')); + t.end(); + }); + }); +}); diff --git a/test/integration/tw_sprite.js b/test/integration/tw_sprite.js new file mode 100644 index 00000000000..918195602a0 --- /dev/null +++ b/test/integration/tw_sprite.js @@ -0,0 +1,9 @@ +const {test} = require('tap'); +const Sprite = require('../../src/sprites/sprite'); +const Runtime = require('../../src/engine/runtime'); + +test('isLazy === false', t => { + const sprite = new Sprite(null, new Runtime()); + t.equal(sprite.isLazy, false); + t.end(); +}); From 420aa4fba85874eccb00dca7b0713358ee6d7c82 Mon Sep 17 00:00:00 2001 From: Thomas Weber Date: Thu, 2 Jan 2025 01:19:06 -0600 Subject: [PATCH 8/9] 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(); }); }); From c9c9d340055bf9e1af42a2959fc03e4fe96779dd Mon Sep 17 00:00:00 2001 From: Thomas Weber Date: Thu, 2 Jan 2025 01:41:53 -0600 Subject: [PATCH 9/9] lazy: fix various test breakage --- src/util/tw-asset-util.js | 11 ----- src/virtual-machine.js | 6 +-- test/integration/tw_font_manager.js | 6 +-- test/integration/tw_save_project_sb3.js | 6 +-- test/integration/tw_serialize_asset_order.js | 50 ++++++++++---------- test/unit/tw_cancellable_mutex.js | 2 +- 6 files changed, 36 insertions(+), 45 deletions(-) diff --git a/src/util/tw-asset-util.js b/src/util/tw-asset-util.js index bd6f09a6867..e12816611d7 100644 --- a/src/util/tw-asset-util.js +++ b/src/util/tw-asset-util.js @@ -37,17 +37,6 @@ class AssetUtil { return runtime.wrapAssetRequest(() => runtime.storage.load(assetType, md5, ext)); } - - /** - * - * @param {JSZip} zip Zip to search - * @param {Storage.assetType} assetType scratch-storage asset type - * @param {string} md5ext full md5 with file extension - * @returns {boolean} - */ - static md5ExtExists (zip, assetType, md5ext) { - - } } module.exports = AssetUtil; diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 8cb6f90ab38..08885475715 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -570,15 +570,15 @@ class VirtualMachine extends EventEmitter { * tw: Serialize the project into a map of files without actually zipping the project. * The buffers returned are the exact same ones used internally, not copies. Avoid directly * manipulating them (except project.json, which is created by this function). - * @returns {Record} Map of file name to the raw data for that file. + * @returns {Promise>} Map of file name to the raw data for that file. */ - saveProjectSb3DontZip () { + async saveProjectSb3DontZip () { const projectJson = this.toJSON(); const files = { 'project.json': new _TextEncoder().encode(projectJson) }; - for (const fileDesc of this.serializeAssets()) { + for (const fileDesc of await this.serializeAssets()) { files[fileDesc.fileName] = fileDesc.fileContent; } diff --git a/test/integration/tw_font_manager.js b/test/integration/tw_font_manager.js index 452fabc687a..3b9f440a142 100644 --- a/test/integration/tw_font_manager.js +++ b/test/integration/tw_font_manager.js @@ -409,7 +409,7 @@ test('deleteFont', t => { t.end(); }); -test('fonts are serialized by VM', t => { +test('fonts are serialized by VM', async t => { const vm = new VirtualMachine(); vm.attachStorage(makeTestStorage()); const {storage, fontManager} = vm.runtime; @@ -427,7 +427,7 @@ test('fonts are serialized by VM', t => { const assets = vm.assets; t.same(assets, [fontAsset], 'font is in vm.assets'); - const serializedAssets = vm.serializeAssets(); + const serializedAssets = await vm.serializeAssets(); t.same(serializedAssets, [ { fileName: '94263e4d553bcec128704e354b659526.ttf', @@ -435,7 +435,7 @@ test('fonts are serialized by VM', t => { } ], 'font is in vm.serializeAssets()'); - const notZippedProject = vm.saveProjectSb3DontZip(); + const notZippedProject = await vm.saveProjectSb3DontZip(); t.equal( notZippedProject['94263e4d553bcec128704e354b659526.ttf'], fontAsset.data, diff --git a/test/integration/tw_save_project_sb3.js b/test/integration/tw_save_project_sb3.js index 60306e334d7..437bdb7c1d6 100644 --- a/test/integration/tw_save_project_sb3.js +++ b/test/integration/tw_save_project_sb3.js @@ -42,7 +42,7 @@ test('saveProjectSb3Stream', async t => { await vm.loadProject(fixture); let receivedDataEvent = false; - const stream = vm.saveProjectSb3Stream(); + const stream = await vm.saveProjectSb3Stream(); stream.on('data', data => { if (receivedDataEvent) { return; @@ -54,7 +54,7 @@ test('saveProjectSb3Stream', async t => { const buffer = await stream.accumulate(); t.type(buffer, ArrayBuffer); - const stream2 = vm.saveProjectSb3Stream('uint8array'); + const stream2 = await vm.saveProjectSb3Stream('uint8array'); const uint8array = await stream2.accumulate(); t.type(uint8array, Uint8Array); @@ -71,7 +71,7 @@ test('saveProjectSb3DontZip', async t => { vm.attachStorage(makeTestStorage()); await vm.loadProject(fixture); - const map = vm.saveProjectSb3DontZip(); + const map = await vm.saveProjectSb3DontZip(); t.equal(map['project.json'][0], '{'.charCodeAt(0)); t.equal(map['d9c625ae1996b615a146ac2a7dbe74d7.svg'].byteLength, 691); t.equal(map['cd21514d0531fdffb22204e0ec5ed84a.svg'].byteLength, 202); diff --git a/test/integration/tw_serialize_asset_order.js b/test/integration/tw_serialize_asset_order.js index 8d296ac2a85..586a65f4397 100644 --- a/test/integration/tw_serialize_asset_order.js +++ b/test/integration/tw_serialize_asset_order.js @@ -12,17 +12,18 @@ test('serializeAssets serialization order', t => { const vm = new VM(); vm.attachStorage(makeTestStorage()); vm.loadProject(fixture).then(() => { - const assets = vm.serializeAssets(); - for (let i = 0; i < assets.length; i++) { - // won't deduplicate assets, so expecting 8 costumes, 7 sounds - // 8 costumes, 6 sounds - if (i < 8) { - t.ok(assets[i].fileName.endsWith('.svg'), `file ${i + 1} is costume`); - } else { - t.ok(assets[i].fileName.endsWith('.wav'), `file ${i + 1} is sound`); + vm.serializeAssets().then(assets => { + for (let i = 0; i < assets.length; i++) { + // won't deduplicate assets, so expecting 8 costumes, 7 sounds + // 8 costumes, 6 sounds + if (i < 8) { + t.ok(assets[i].fileName.endsWith('.svg'), `file ${i + 1} is costume`); + } else { + t.ok(assets[i].fileName.endsWith('.wav'), `file ${i + 1} is sound`); + } } - } - t.end(); + t.end(); + }); }); }); @@ -79,20 +80,21 @@ test('saveProjectSb3DontZip', t => { const vm = new VM(); vm.attachStorage(makeTestStorage()); vm.loadProject(fixture).then(() => { - const exported = vm.saveProjectSb3DontZip(); - const files = Object.keys(exported); - - for (let i = 0; i < files.length; i++) { - // 6 costumes, 6 sounds - if (i === 0) { - t.equal(files[i], 'project.json', 'first file is project.json'); - } else if (i < 7) { - t.ok(files[i].endsWith('.svg'), `file ${i + 1} is costume`); - } else { - t.ok(files[i].endsWith('.wav'), `file ${i + 1} is sound`); + vm.saveProjectSb3DontZip().then(exported => { + const files = Object.keys(exported); + + for (let i = 0; i < files.length; i++) { + // 6 costumes, 6 sounds + if (i === 0) { + t.equal(files[i], 'project.json', 'first file is project.json'); + } else if (i < 7) { + t.ok(files[i].endsWith('.svg'), `file ${i + 1} is costume`); + } else { + t.ok(files[i].endsWith('.wav'), `file ${i + 1} is sound`); + } } - } - - t.end(); + + t.end(); + }); }); }); diff --git a/test/unit/tw_cancellable_mutex.js b/test/unit/tw_cancellable_mutex.js index 682ca325db5..3f3639666cd 100644 --- a/test/unit/tw_cancellable_mutex.js +++ b/test/unit/tw_cancellable_mutex.js @@ -88,7 +88,7 @@ test('cancellation', t => { t.fail(); })); - // After dispoing, existing operation should be cancelled, queue should be cleared. + // After cancelling, existing operation should be able to see that, and queue should be cleared. mutex.cancel(); t.equal(isCancelled(), true);